Compare commits
49
Commits
4131dca66e
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0632d3c7b | ||
|
|
e713d92d49 | ||
|
|
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 | ||
|
|
6cc1bc23f7 | ||
|
|
712e94803f | ||
|
|
ef5849377a | ||
|
|
8799d72d47 | ||
|
|
30b4b0504a | ||
|
|
9ec65698b6 | ||
|
|
ce131bf399 | ||
|
|
283ac4ca0a | ||
|
|
b0a02e56b0 | ||
|
|
0b67382076 | ||
|
|
877e800f82 | ||
|
|
2106ba3d00 | ||
|
|
d6e995a954 | ||
|
|
5cec25736a | ||
|
|
7acfabfa70 | ||
|
|
fb3c5671c6 | ||
|
|
3421851b6f | ||
|
|
46a9587160 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
target/*
|
||||
!target/userbase.jar
|
||||
!target/app.jar
|
||||
src
|
||||
.idea
|
||||
*.iml
|
||||
|
||||
@@ -25,7 +25,22 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- 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 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -38,6 +53,7 @@ jobs:
|
||||
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
|
||||
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
|
||||
echo "MB=$(echo ${GITHUB_SHA} | cut -c1-6)" >> "$GITHUB_ENV"
|
||||
|
||||
# 可选:清空 m2 缓存(仅手动勾选 cache_bust 时)
|
||||
- name: 可选清空 m2 缓存
|
||||
@@ -55,18 +71,30 @@ jobs:
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||
docker rm -f mavenbuild-${MB} 2>/dev/null || true
|
||||
docker run -d --name mavenbuild-${MB} \
|
||||
--network infra-infra-net \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/build/
|
||||
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." mavenbuild-${MB}:/build/
|
||||
docker exec mavenbuild-${MB} sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
|
||||
docker exec mavenbuild-${MB} sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild-${MB} sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp mavenbuild-${MB}:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild-${MB}
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
</dependencyManagement>
|
||||
|
||||
<dependencies>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>com.yunhai.platform</groupId><artifactId>yh-mobile-auth</artifactId><version>1.1.0-mobauth03-rc.1</version></dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
@@ -195,6 +197,18 @@
|
||||
<groupId>com.yunhai.platform</groupId>
|
||||
<artifactId>yh-env-config-spring-boot-starter</artifactId>
|
||||
</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 过滤器注册与
|
||||
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
|
||||
@@ -256,6 +270,11 @@
|
||||
<directory>C:/ub-build2/userbase</directory>
|
||||
</build>
|
||||
</profile>
|
||||
|
||||
<profile>
|
||||
<id>mobauth03</id>
|
||||
<build><directory>${mobauth03.output}/${project.artifactId}</directory></build>
|
||||
</profile>
|
||||
</profiles>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -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
|
||||
@@ -30,6 +30,7 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
@@ -40,7 +41,6 @@ import java.util.Map;
|
||||
* 拦截 {@code controller.oapi} 包下所有方法,规则:<br>
|
||||
* - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;<br>
|
||||
* - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;<br>
|
||||
* - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);<br>
|
||||
* - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br>
|
||||
* - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br>
|
||||
*
|
||||
@@ -52,9 +52,6 @@ import java.util.Map;
|
||||
@Component
|
||||
public class OapiLogAspect {
|
||||
|
||||
/** 内部密钥端点控制器,排除(响应含 appSecret 明文)。 */
|
||||
private static final String INTERNAL_CONTROLLER = "com.yunhai.userbase.controller.oapi.SignKeyInternalController";
|
||||
|
||||
/** GET 默认不记录,开启本配置后全量记录。 */
|
||||
@Value("${oapi.log-get:false}")
|
||||
private boolean logGet;
|
||||
@@ -67,10 +64,6 @@ public class OapiLogAspect {
|
||||
|
||||
@Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
|
||||
public Object around(ProceedingJoinPoint point) throws Throwable {
|
||||
if (INTERNAL_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
|
||||
return point.proceed();
|
||||
}
|
||||
|
||||
HttpServletRequest request = getRequest();
|
||||
String httpMethod = request != null ? request.getMethod() : "";
|
||||
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
|
||||
@@ -85,18 +78,17 @@ public class OapiLogAspect {
|
||||
SysOapiLog entity = buildEntity(point, request, httpMethod, oapiLog);
|
||||
|
||||
boolean saveResponse = oapiLog != null ? oapiLog.saveResponse() : true;
|
||||
int maxResponseSize = oapiLog != null ? oapiLog.maxResponseSize() : 10240;
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(200);
|
||||
fillResponse(entity, result, saveResponse, maxResponseSize);
|
||||
fillResponse(entity, result, saveResponse);
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(500);
|
||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
// 业务异常(DException/BizException)同样构造实际返回的响应体落库,便于详情下载
|
||||
fillErrorResponse(entity, e, saveResponse, maxResponseSize);
|
||||
fillErrorResponse(entity, e, saveResponse);
|
||||
throw e;
|
||||
} finally {
|
||||
entity.setCostTime(System.currentTimeMillis() - start);
|
||||
@@ -224,20 +216,20 @@ public class OapiLogAspect {
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入响应结果(脱敏 → 截断 → GZIP)。
|
||||
* 写入响应结果(脱敏 → GZIP,不截断)。
|
||||
*/
|
||||
private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse, int maxResponseSize) {
|
||||
private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse) {
|
||||
if (!saveResponse || result == null) {
|
||||
return;
|
||||
}
|
||||
writeResponseData(entity, result, maxResponseSize);
|
||||
writeResponseData(entity, result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 异常路径:与 {@code GlobalExceptionHandler} 保持一致的响应体(DException/BizException → ApiResponse),
|
||||
* 使日志下载内容等于调用方实际收到的 body;其余异常不构造(响应不可预期)。
|
||||
*/
|
||||
private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse, int maxResponseSize) {
|
||||
private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse) {
|
||||
if (!saveResponse) {
|
||||
return;
|
||||
}
|
||||
@@ -245,7 +237,7 @@ public class OapiLogAspect {
|
||||
if (response == null) {
|
||||
return;
|
||||
}
|
||||
writeResponseData(entity, response, maxResponseSize);
|
||||
writeResponseData(entity, response);
|
||||
}
|
||||
|
||||
private ApiResponse<?> toApiResponse(Throwable e) {
|
||||
@@ -260,7 +252,7 @@ public class OapiLogAspect {
|
||||
return null;
|
||||
}
|
||||
|
||||
private void writeResponseData(SysOapiLog entity, Object payload, int maxResponseSize) {
|
||||
private void writeResponseData(SysOapiLog entity, Object payload) {
|
||||
if (payload == null) {
|
||||
return;
|
||||
}
|
||||
@@ -269,10 +261,8 @@ public class OapiLogAspect {
|
||||
if (resp == null) {
|
||||
return;
|
||||
}
|
||||
if (resp.length() > maxResponseSize) {
|
||||
resp = resp.substring(0, maxResponseSize) + "...[截断]";
|
||||
}
|
||||
entity.setResponseData(GzipUtil.compress(resp));
|
||||
entity.setResponseSize(resp.getBytes(StandardCharsets.UTF_8).length);
|
||||
} catch (Exception e) {
|
||||
log.warn("开放接口日志响应序列化失败, uri={}", entity.getRequestUri(), e);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.servlet.http.HttpSession;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@@ -45,9 +46,6 @@ public class OperationLogAspect {
|
||||
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
|
||||
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
|
||||
|
||||
/** GET 自动记录时使用的默认响应保存上限(10KB)。 */
|
||||
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
|
||||
|
||||
@Autowired
|
||||
private ISysOperationLogService sysOperationLogService;
|
||||
|
||||
@@ -76,13 +74,12 @@ public class OperationLogAspect {
|
||||
|
||||
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
|
||||
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
|
||||
int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE;
|
||||
|
||||
Object result = null;
|
||||
try {
|
||||
result = point.proceed();
|
||||
entity.setStatusCode(200);
|
||||
fillResponse(entity, result, saveResponse, maxResponseSize);
|
||||
fillResponse(entity, result, saveResponse);
|
||||
} catch (Throwable e) {
|
||||
entity.setStatusCode(500);
|
||||
entity.setErrorMsg(truncate(e.getMessage(), 2000));
|
||||
@@ -174,18 +171,16 @@ public class OperationLogAspect {
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入响应结果(GZIP 压缩 + 截断)。
|
||||
* 写入响应结果(全量 GZIP 压缩入库,不再截断)。
|
||||
*/
|
||||
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) {
|
||||
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse) {
|
||||
if (!saveResponse || result == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String resp = JSON.toJSONString(result);
|
||||
if (resp.length() > maxResponseSize) {
|
||||
resp = resp.substring(0, maxResponseSize) + "...[截断]";
|
||||
}
|
||||
entity.setResponseData(GzipUtil.compress(resp));
|
||||
entity.setResponseSize(resp.getBytes(StandardCharsets.UTF_8).length);
|
||||
} catch (Exception e) {
|
||||
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.yunhai.userbase.common;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||
import org.springframework.validation.BindException;
|
||||
import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
@@ -13,6 +15,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
|
||||
import javax.validation.ConstraintViolation;
|
||||
import javax.validation.ConstraintViolationException;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@@ -66,6 +69,27 @@ public class GlobalExceptionHandler {
|
||||
return paramError("参数类型不匹配: " + ex.getName());
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求体反序列化失败(Jackson 在进 Controller 前抛出,@Valid 兜不到)。典型如 birthday 等非空字段格式非法:
|
||||
* 从异常最内层 {@link JsonMappingException} 的 path 取失败字段名,birthday 给点名提示,其余走通用文案。
|
||||
* 空串/null 由 Jackson JSR-310 归一为 null、不抛,故不会误入此分支(满足「空不管」)。
|
||||
*/
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public Map<String, Object> handleNotReadable(HttpMessageNotReadableException ex) {
|
||||
String field = null;
|
||||
Throwable cause = ex.getMostSpecificCause();
|
||||
if (cause instanceof JsonMappingException) {
|
||||
List<JsonMappingException.Reference> path = ((JsonMappingException) cause).getPath();
|
||||
if (path != null && !path.isEmpty()) {
|
||||
field = path.get(path.size() - 1).getFieldName();
|
||||
}
|
||||
}
|
||||
if ("birthday".equals(field)) {
|
||||
return paramError("birthday 字段类型需为yyyy-MM-dd");
|
||||
}
|
||||
return paramError("请求参数格式错误");
|
||||
}
|
||||
|
||||
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
|
||||
if (fieldErrors == null || fieldErrors.isEmpty()) {
|
||||
return "参数校验失败";
|
||||
|
||||
@@ -32,9 +32,4 @@ public @interface OapiLog {
|
||||
* 是否保存响应结果(GZIP 压缩后入库)。
|
||||
*/
|
||||
boolean saveResponse() default true;
|
||||
|
||||
/**
|
||||
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
|
||||
*/
|
||||
int maxResponseSize() default 10240;
|
||||
}
|
||||
|
||||
@@ -27,9 +27,4 @@ public @interface OperationLog {
|
||||
* 是否保存响应结果(GZIP 压缩后入库)。
|
||||
*/
|
||||
boolean saveResponse() default true;
|
||||
|
||||
/**
|
||||
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
|
||||
*/
|
||||
int maxResponseSize() default 10240;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import java.util.List;
|
||||
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
|
||||
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
|
||||
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
|
||||
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
||||
* 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
|
||||
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
|
||||
*
|
||||
* @author chenx
|
||||
@@ -29,16 +29,18 @@ import java.util.List;
|
||||
public class ManageAccessInterceptor implements HandlerInterceptor {
|
||||
|
||||
/**
|
||||
* 白名单(Ant 风格,* 匹配 platform 前缀):
|
||||
* 白名单(显式 /api 前缀,路径闭集):
|
||||
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
|
||||
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
|
||||
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
|
||||
*/
|
||||
private static final List<String> WHITELIST = Arrays.asList(
|
||||
"/*/base/GetUserInfo",
|
||||
"/*/base/GetUserScope",
|
||||
"/*/base/GetSysDict",
|
||||
"/*/base/RedirectTo/*",
|
||||
"/*/env-config");
|
||||
"/api/base/GetUserInfo",
|
||||
"/api/base/GetUserScope",
|
||||
"/api/base/GetSysDict",
|
||||
"/api/base/RedirectTo/*",
|
||||
"/api/appearance",
|
||||
"/api/env-config");
|
||||
|
||||
private static final AntPathMatcher MATCHER = new AntPathMatcher();
|
||||
|
||||
|
||||
@@ -87,6 +87,64 @@ public final class CacheNames {
|
||||
*/
|
||||
public static final String APP_KEY_TO_ID = "appKeyToId";
|
||||
|
||||
/**
|
||||
* 用户当前激活身份(GET /oapi/identity/current 读路径,压测占比 40%)。<br>
|
||||
* key 规则:{@code 'id:' + userId};TTL 5min(用户身份型:脏 currentRole = 权限事故,与 boaUser 同档同事件);<br>
|
||||
* 存放:Redis;依赖表:sys_user_current_role、sys_user_role_rel、sys_user_group_rel、sys_group;<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
|
||||
* (身份切换/换组/回填等全部用户域写口 AFTER_COMMIT 精确驱逐)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent}(结果内嵌校名,组改名后不能吃 5min 旧值)。<br>
|
||||
* 设计说明:<b>不用 sync=true</b>——方法对「连主身份都没有」的用户合法返回 null(unless 拦截不入缓存),
|
||||
* SD Redis 2.7 的 sync 路径对 null 会无条件 put 并在 {@code disableCachingNullValues} 下抛 IAE
|
||||
* (同 {@link com.yunhai.userbase.service.impl.BoaUserLoader} 既有纪律)。
|
||||
*/
|
||||
public static final String IDENTITY_CURRENT = "identityCurrent";
|
||||
|
||||
/**
|
||||
* 用户可见应用列表(GET /oapi/app/list 读路径,压测占比 20%,每请求 ~8 条 SQL)。<br>
|
||||
* key 规则:{@code 'u:' + userId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())
|
||||
* + ':s:' + (schoolId 为 null ? 'auto' : schoolId)}——client 维度共 1+7=8 个;
|
||||
* 学校维度:未显式传校归一为字面量 'auto'(生效学校随 userId 由身份/主归属推导,可精确枚举驱逐),
|
||||
* 显式传校后缀为学校 id(不可枚举,用户自身变更场景靠 TTL 自愈,别名/授权/组/应用变更均整清);<br>
|
||||
* TTL 60s(结果敏感型短档:脏了=用户看到不该看的菜单;叠加全哨口事件后 TTL 仅兜直改 DB);<br>
|
||||
* 存放:Redis;依赖表:sys_group_app_rel、sys_group_module_perm、sys_user_app_role_rel、sys_app_role、
|
||||
* sys_app_role_module_rel、app_module、app_category、application_info、sys_group、
|
||||
* sys_school_app_override、sys_school_category_override(2026-09-10 起后两者经
|
||||
* SchoolAppOverrideService.applyAlias 正式在读链上:返回前合并学校私有别名/图标/排序);<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(应用授权域 24 写口整清,
|
||||
* 含学校别名覆盖全部写口)+
|
||||
* {@link com.yunhai.userbase.event.UserChangedEvent}(按用户 16 条可枚举 key 精确驱逐)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
|
||||
* (组移动改变继承开通面 / 应用启停排序删除整清)。
|
||||
*/
|
||||
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>
|
||||
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
|
||||
* 整批重写组关系无法从事件反推旧 key,多维只能整清,写流量下会打穿缓存);<br>
|
||||
* TTL 5min(用户身份型;全部写口有 UserChangedEvent,TTL 仅兜直改 DB);<br>
|
||||
* 存放:Redis;依赖表:sys_user_group_rel;<br>
|
||||
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}(含 groupState 写口本次补发事件)+
|
||||
* {@link com.yunhai.userbase.event.GroupChangedEvent}(组删除级联的卫生性整清)。<br>
|
||||
* 无组关系用户为空 Map 恒非 null,sync=true 安全(穿透防护天然成立)。
|
||||
*/
|
||||
public static final String USER_GROUP_REL = "userGroupRel";
|
||||
|
||||
// ===================== 注册表元数据(驱动 CacheConfig 与对账接口) =====================
|
||||
|
||||
/** 存放策略:Redis(跨重启、单条小总量大)或 LOCAL(Caffeine 仅本地,单条大/读频极高/配置型小数据)。 */
|
||||
@@ -174,6 +232,28 @@ public final class CacheNames {
|
||||
"#appKey",
|
||||
"ApplicationInfoCacheChangedEvent → CacheInvalidationListener(搭车 clear)",
|
||||
"application_info"));
|
||||
m.put(IDENTITY_CURRENT, new Meta(IDENTITY_CURRENT, Store.REDIS, Duration.ofMinutes(5), 0.1,
|
||||
"'id:'+userId",
|
||||
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
|
||||
"sys_user_current_role, sys_user_role_rel, sys_user_group_rel, sys_group"));
|
||||
m.put(USER_APP_LIST, new Meta(USER_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
|
||||
"'u:'+userId+':c:'+(clientType?.name()?:'all')+':s:'+(schoolId?:'auto')(8 client × auto/显式校)",
|
||||
"AppAuthzChangedEvent(整清,含学校别名写口)+ UserChangedEvent(按用户 16 条可枚举 key 驱逐,"
|
||||
+ "显式学校后缀 TTL 自愈)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent(整清)"
|
||||
+ " → CacheInvalidationListener",
|
||||
"sys_group_app_rel, sys_group_module_perm, sys_user_app_role_rel, sys_app_role,"
|
||||
+ " sys_app_role_module_rel, app_module, app_category, application_info, sys_group,"
|
||||
+ " sys_school_app_override, sys_school_category_override(2026-09-10 起在读链:applyAlias 合并别名)"));
|
||||
m.put(USER_GROUP_REL, new Meta(USER_GROUP_REL, Store.REDIS, Duration.ofMinutes(5), 0.1,
|
||||
"'u:'+userId(整张 groupId→status Map)",
|
||||
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
|
||||
"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);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ public enum Config
|
||||
Inst;
|
||||
|
||||
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
|
||||
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||
// "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
|
||||
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
|
||||
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi
|
||||
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话),路径闭集:/manage 等旧前缀随之 404
|
||||
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 token;UserBase 自身不挂 mobiapi 控制器,仅供常量对齐)
|
||||
public static final String OPEN_API_PATH = "/oapi/";
|
||||
|
||||
public String getString(String key)
|
||||
|
||||
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
|
||||
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
|
||||
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。
|
||||
// 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
|
||||
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
|
||||
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
|
||||
// /v3/api-docs、/swagger-ui:接口文档工具链。
|
||||
registry.addInterceptor(manageAccessInterceptor)
|
||||
.addPathPatterns("/{platform}/**")
|
||||
.addPathPatterns("/api/**")
|
||||
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
|
||||
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
|
||||
"/webjars/**");
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import java.time.Clock;
|
||||
|
||||
@Configuration(proxyBeanMethods=false)
|
||||
public class MobileStrongConfiguration {
|
||||
@Bean public FilterRegistrationBean<StrongRequestFilter> userBaseMobileStrong(
|
||||
@org.springframework.beans.factory.annotation.Qualifier("stringRedisTemplate") ObjectProvider<StringRedisTemplate> redis,
|
||||
@Value("${yh.mobile.env:}") String env,
|
||||
@Value("${yh.mobile.app-id:}") String appId,@Value("${yh.mobile.key-registry-file:}") String keyFile,
|
||||
@Value("${yh.mobile.key-registry-sha256:}") String keyHash) {
|
||||
StrongRequestFilter filter=new StrongRequestFilter(()-> {
|
||||
StringRedisTemplate r=redis.getIfAvailable();if(r==null)return null;
|
||||
return new RequestAssertion(new PinnedKeyRegistry(keyFile,keyHash),new RedisReplay(r),Clock.systemUTC(),"userbase");
|
||||
},r->{String path=r.getRequestURI().substring(r.getContextPath().length());String action=SensitivePaths.action(r.getMethod(),path);
|
||||
if(action==null)return null;
|
||||
AuthFailure.require(!env.isEmpty()&&!appId.isEmpty(),503,"CALLER_VERIFIER_UNAVAILABLE");
|
||||
return new StrongRequestFilter.Required(env,appId,action,path);});
|
||||
FilterRegistrationBean<StrongRequestFilter> bean=new FilterRegistrationBean<>(filter);
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE+3);bean.addUrlPatterns("/*");return bean;
|
||||
}
|
||||
}
|
||||
@@ -25,8 +25,8 @@ import java.io.IOException;
|
||||
* 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br>
|
||||
* <br>
|
||||
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
|
||||
* 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**})
|
||||
* 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
|
||||
* 但 UserBase 作为用户中心的开放接口({/open/api/**} 与 {/oapi/**})绝不允许匿名访问。
|
||||
* 本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
|
||||
* 已验签(属性存在)则放行,否则返回 401。<br>
|
||||
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br>
|
||||
* <br>
|
||||
@@ -39,7 +39,7 @@ import java.io.IOException;
|
||||
* 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties({ SignKeyInternalProperties.class, AppAuthProperties.class })
|
||||
@EnableConfigurationProperties({ AppAuthProperties.class })
|
||||
public class OpenApiAuthEnforceConfiguration {
|
||||
|
||||
@Bean
|
||||
@@ -51,21 +51,6 @@ public class OpenApiAuthEnforceConfiguration {
|
||||
return bean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 内部开放接口(/oapi/internal/*)来源 IP 白名单:
|
||||
* 在验签过滤器之前先执行(HIGHEST_PRECEDENCE + 9),先卡掉公网来源再进行后续处理。
|
||||
* 配置项 yh.sign-key-internal.ips / enabled 可由 Nacos 下发。
|
||||
*/
|
||||
@Bean
|
||||
public FilterRegistrationBean<SignKeyInternalIpFilter> signKeyInternalIpFilter(
|
||||
SignKeyInternalProperties properties) {
|
||||
FilterRegistrationBean<SignKeyInternalIpFilter> bean = new FilterRegistrationBean<>();
|
||||
bean.setFilter(new SignKeyInternalIpFilter(properties));
|
||||
bean.addUrlPatterns("/oapi/internal/*");
|
||||
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9);
|
||||
return bean;
|
||||
}
|
||||
|
||||
/**
|
||||
* UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br>
|
||||
* 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider,
|
||||
@@ -83,7 +68,9 @@ public class OpenApiAuthEnforceConfiguration {
|
||||
throws IOException, ServletException {
|
||||
HttpServletRequest req = (HttpServletRequest) request;
|
||||
HttpServletResponse res = (HttpServletResponse) response;
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
|
||||
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null
|
||||
&& !(req.getAttribute(com.yunhai.platform.mobile.StrongRequestFilter.VERIFIED)
|
||||
instanceof com.yunhai.platform.mobile.CallerAppContext)) {
|
||||
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import javax.servlet.FilterChain;
|
||||
import javax.servlet.ServletException;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.net.Inet6Address;
|
||||
import java.net.InetAddress;
|
||||
import java.net.UnknownHostException;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 内部开放接口(/oapi/internal/*)来源 IP 白名单过滤器。<p>
|
||||
* 防止密钥兜底端点(/oapi/internal/sign-key/{appId})被公网直接枚举调用;
|
||||
* 只允许内网 / 显式配置的白名单来源通过,未命中返回 403。<p>
|
||||
* 支持 X-Forwarded-For / X-Real-IP 等常见代理头解析,取最左侧真实来源 IP 做校验。
|
||||
*/
|
||||
public class SignKeyInternalIpFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(SignKeyInternalIpFilter.class);
|
||||
|
||||
private final SignKeyInternalProperties properties;
|
||||
|
||||
public SignKeyInternalIpFilter(SignKeyInternalProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
if (!properties.isEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
String sourceIp = resolveSourceIp(request);
|
||||
List<String> effectiveIps = properties.getEffectiveIps();
|
||||
if (CollectionUtils.isEmpty(effectiveIps) || matchAny(sourceIp, effectiveIps)) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
log.warn("[SignKeyInternal] 拒绝非白名单来源访问内部接口: uri={}, sourceIp={}, whitelist={}",
|
||||
request.getRequestURI(), sourceIp, effectiveIps);
|
||||
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write("{\"code\":403,\"msg\":\"内部接口不允许从该来源访问\",\"data\":null}");
|
||||
response.getWriter().flush();
|
||||
}
|
||||
|
||||
private static String resolveSourceIp(HttpServletRequest request) {
|
||||
String[] headers = new String[] {
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
"Proxy-Client-IP",
|
||||
"WL-Proxy-Client-IP",
|
||||
"HTTP_CLIENT_IP",
|
||||
"HTTP_X_FORWARDED_FOR"
|
||||
};
|
||||
for (String header : headers) {
|
||||
String raw = request.getHeader(header);
|
||||
if (raw == null || raw.isEmpty() || "unknown".equalsIgnoreCase(raw)) {
|
||||
continue;
|
||||
}
|
||||
// X-Forwarded-For 可能是 多个IP逗号分隔,取最左侧
|
||||
String first = raw.split(",")[0].trim();
|
||||
if (!first.isEmpty()) {
|
||||
return first;
|
||||
}
|
||||
}
|
||||
return request.getRemoteAddr();
|
||||
}
|
||||
|
||||
private static boolean matchAny(String ip, List<String> cidrs) {
|
||||
for (String cidr : cidrs) {
|
||||
if (matchCidr(ip, cidr)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static boolean matchCidr(String ip, String cidr) {
|
||||
if (ip == null || cidr == null) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int prefixLen;
|
||||
String network;
|
||||
if (cidr.contains("/")) {
|
||||
String[] parts = cidr.split("/");
|
||||
network = parts[0].trim();
|
||||
prefixLen = Integer.parseInt(parts[1].trim());
|
||||
} else {
|
||||
network = cidr.trim();
|
||||
// 判断地址族决定默认前缀长度
|
||||
InetAddress na = InetAddress.getByName(network);
|
||||
prefixLen = (na instanceof Inet6Address) ? 128 : 32;
|
||||
}
|
||||
InetAddress ipAddr = InetAddress.getByName(ip);
|
||||
InetAddress netAddr = InetAddress.getByName(network);
|
||||
if (ipAddr.getClass() != netAddr.getClass()) {
|
||||
return false;
|
||||
}
|
||||
byte[] ipBytes = ipAddr.getAddress();
|
||||
byte[] netBytes = netAddr.getAddress();
|
||||
int bits = prefixLen;
|
||||
for (int i = 0; i < ipBytes.length && bits > 0; i++) {
|
||||
int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits));
|
||||
if ((ipBytes[i] & mask) != (netBytes[i] & mask)) {
|
||||
return false;
|
||||
}
|
||||
bits -= 8;
|
||||
}
|
||||
return true;
|
||||
} catch (UnknownHostException | NumberFormatException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package com.yunhai.userbase.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 内部开放接口(/oapi/internal/*)访问白名单 IP 配置。<br>
|
||||
* 支持单 IP、CIDR(如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.1/32)。<br>
|
||||
* <p>
|
||||
* 可在 Nacos 上配置,例如:<pre>
|
||||
* yh:
|
||||
* sign-key-internal:
|
||||
* ips:
|
||||
* - 10.0.0.0/8
|
||||
* - 172.16.0.0/12
|
||||
* - 192.168.0.0/16
|
||||
* - 127.0.0.1/32
|
||||
* - 172.20.10.5/32 # 某台固定机器
|
||||
* </pre>
|
||||
* 未配置时,默认允许 10/172.16-31/192.168 私有段 + 127.0.0.1(方便本地开发/内网部署)。
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "yh.sign-key-internal")
|
||||
public class SignKeyInternalProperties {
|
||||
|
||||
/** 显式允许的来源 IP(CIDR 或 单 IP)。为空时使用默认私有段规则。 */
|
||||
private List<String> ips = new ArrayList<>();
|
||||
|
||||
/** 是否启用白名单校验;设置 false 可临时关闭(生产不建议)。 */
|
||||
private boolean enabled = true;
|
||||
|
||||
public List<String> getIps() { return ips; }
|
||||
|
||||
public void setIps(List<String> ips) { this.ips = ips; }
|
||||
|
||||
public boolean isEnabled() { return enabled; }
|
||||
|
||||
public void setEnabled(boolean enabled) { this.enabled = enabled; }
|
||||
|
||||
/** 未配置 ips 时返回默认私有段 + localhost 规则。 */
|
||||
public List<String> getEffectiveIps() {
|
||||
if (ips != null && !ips.isEmpty()) {
|
||||
return ips;
|
||||
}
|
||||
return Arrays.asList(
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"127.0.0.1/32",
|
||||
"0:0:0:0:0:0:0:1/128"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -108,16 +108,17 @@ public class AppRoleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询角色已绑定的用户列表。
|
||||
* 查询角色已绑定的用户列表(一行 = 一条「用户×组」绑定,含部门路径)。<br>
|
||||
* 路径查询时按 groupId 实时拼接;非超管按数据范围过滤行并裁剪路径(学校管理员只看到本校路径)。
|
||||
*/
|
||||
@GetMapping("/RoleUsers")
|
||||
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
return appRoleService.findRoleUsers(roleId);
|
||||
return appRoleService.findRoleUsers(roleId, accessScopeService.scopeGroupIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色绑定用户保存(角色维度,全量覆盖)。
|
||||
* 角色绑定用户保存(角色维度,增量添加「用户×组」绑定)。
|
||||
*/
|
||||
@OperationLog("保存角色用户绑定")
|
||||
@PostMapping("SaveRoleUsers")
|
||||
@@ -127,7 +128,7 @@ public class AppRoleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户绑定角色保存(用户维度,全量覆盖)。
|
||||
* 用户绑定角色保存(用户维度,按组覆盖:只覆盖该用户在指定组下的绑定)。
|
||||
*/
|
||||
@OperationLog("保存用户角色绑定")
|
||||
@PostMapping("SaveUserRoles")
|
||||
@@ -137,13 +138,14 @@ public class AppRoleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。<br>
|
||||
* 移除单条「用户×组×角色」绑定(精确删除一行,非全量重提)。<br>
|
||||
* 前端「用户管理 - 删除」调用此接口。
|
||||
*/
|
||||
@OperationLog("移除角色用户绑定")
|
||||
@PostMapping("RemoveRoleUser")
|
||||
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
|
||||
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId,
|
||||
@RequestParam Integer groupId) {
|
||||
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
|
||||
appRoleService.removeRoleUser(roleId, userId);
|
||||
appRoleService.removeRoleUser(roleId, userId, groupId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ import com.yunhai.userbase.service.resolve.AccessScopeService;
|
||||
import com.yunhai.userbase.utils.PageUtils;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.data.web.SortDefault;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
@@ -195,8 +194,14 @@ public class AppSettingsController {
|
||||
|
||||
@GetMapping("/AppList")
|
||||
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
|
||||
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort,
|
||||
@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()) {
|
||||
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
|
||||
AccessScope scope = accessScopeService.resolveCurrentScope();
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.yunhai.userbase.config.CacheConfig;
|
||||
import com.yunhai.userbase.config.CacheNames;
|
||||
import com.yunhai.userbase.config.CacheNames.Meta;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@@ -26,12 +27,15 @@ import java.util.TreeSet;
|
||||
/**
|
||||
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
|
||||
*
|
||||
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
|
||||
* {@code GET /api/cache/registry} 输出三块内容:<br>
|
||||
* <ul>
|
||||
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
|
||||
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
|
||||
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
|
||||
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
|
||||
* <li><b>identityCurrentNullLookups</b>:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存
|
||||
* (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比,
|
||||
* 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。</li>
|
||||
* </ul>
|
||||
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
|
||||
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
|
||||
@@ -44,6 +48,10 @@ public class CacheRegistryController {
|
||||
@Autowired
|
||||
private CacheManager cacheManager;
|
||||
|
||||
/** 无身份查询计数来源(identityCurrent 对 null 不缓存,计数即无身份请求数)。 */
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
/** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */
|
||||
@Autowired
|
||||
@Qualifier("caffeineCacheManager")
|
||||
@@ -91,6 +99,7 @@ public class CacheRegistryController {
|
||||
result.put("caches", caches);
|
||||
result.put("unregisteredRuntimeCaches", unregistered);
|
||||
result.put("declaredButUnusedCaches", unused);
|
||||
result.put("identityCurrentNullLookups", identityService.getNullIdentityLookupCount());
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -148,29 +148,46 @@ public class GroupAuthzController {
|
||||
|
||||
/**
|
||||
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。<br>
|
||||
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
|
||||
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。<br>
|
||||
* groupId 可选:传入时只返回该组下的绑定(编辑弹窗按所在组回显角色用);为空返回全部绑定聚合去重。
|
||||
*/
|
||||
@GetMapping("/UserRoles")
|
||||
public List<UserRoleVo> userRoles(@RequestParam Integer userId) {
|
||||
public List<UserRoleVo> userRoles(@RequestParam Integer userId,
|
||||
@RequestParam(required = false) Integer groupId) {
|
||||
if (!accessScopeService.resolveCurrentScope().isSuper()
|
||||
&& !accessScopeService.userIntersectsScope(userId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return groupAuthzService.findUserRoles(userId);
|
||||
return groupAuthzService.findUserRoles(userId, groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
|
||||
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
|
||||
* 非超管:目标用户归属组与数据范围无交集时返回空集合。
|
||||
* groupId 可选(上下文组):传入且用户确属该组时只画该组的祖先链——中间列表每行是「用户×组」对,
|
||||
* 所属部门只展示行所在组的链路,而非用户全部归属组。<br>
|
||||
* 非超管:目标用户归属组与数据范围无交集时返回空集合;且树按数据范围剪枝——
|
||||
* 学校管理员视角只展示到学校组(区域组/平台根不在其范围内,不再冒头到根节点)。
|
||||
*/
|
||||
@GetMapping("/UserGroupTree")
|
||||
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) {
|
||||
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId,
|
||||
@RequestParam(required = false) Integer groupId) {
|
||||
if (!accessScopeService.resolveCurrentScope().isSuper()
|
||||
&& !accessScopeService.userIntersectsScope(userId)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return groupAuthzService.findUserGroupTree(userId);
|
||||
return groupAuthzService.findUserGroupTree(userId, accessScopeService.scopeGroupIds(), groupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限一览:查询<b>组本身</b>的有效已授权应用模块树(点击「所属部门」树节点联动右侧「已授权应用」面板)。<br>
|
||||
* 判定口径与组授权/权限一览的组侧一致(开通池=本组+祖先并集、就近原则、子组继承闸门、学校截止),
|
||||
* 不并入用户角色授权;目标组须在当前管理端数据范围内。
|
||||
*/
|
||||
@GetMapping("/GroupPermissionTree")
|
||||
public List<UserPermissionCategoryVo> groupPermissionTree(@RequestParam Integer groupId) {
|
||||
accessScopeService.requireGroupInScope(groupId);
|
||||
return groupAuthzService.findGroupPermissionTree(groupId);
|
||||
}
|
||||
|
||||
// ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) =====================
|
||||
|
||||
@@ -27,7 +27,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 推送订阅控制器:测试推送 + 推送日志记录 / 重试。
|
||||
* 推送订阅控制器:手动触发推送(组/用户全量同步)+ 推送日志记录 / 重试。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/14
|
||||
@@ -45,17 +45,14 @@ public class PushSubscriptionController {
|
||||
@Autowired
|
||||
private AccessScopeService accessScopeService;
|
||||
|
||||
/** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */
|
||||
/** 手动触发一次推送(投递 MQ),供应用推送设置的组/用户全量同步等场景调用。 */
|
||||
@PostMapping("Test")
|
||||
public Map<String, Object> test(@RequestBody PushTestRequest request) {
|
||||
accessScopeService.requireSuper(); // 日志/推送管理仅超管
|
||||
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||
Map<String, Object> preview = pushDispatcher.previewManual(
|
||||
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("sent", true);
|
||||
result.put("message", "已投递推送队列,可在「推送日志」查看实际响应与耗时");
|
||||
result.put("preview", preview);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.yunhai.userbase.common.annotation.OperationLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupIncludeChildrenRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
|
||||
@@ -65,6 +66,46 @@ public class SysGroupController {
|
||||
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")
|
||||
@GetMapping("/List")
|
||||
public List<SysGroupVo> list(SysGroupQuery query) {
|
||||
|
||||
@@ -28,7 +28,10 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
@@ -66,10 +69,15 @@ public class SysUserController {
|
||||
@OperationLog("编辑用户")
|
||||
@PostMapping("Edit/{id}")
|
||||
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
|
||||
// 关键防线:目标用户本身必须与数据范围有交集。request.groupId 只是前端上下文,
|
||||
// 伪造 groupId(范围内任一组)+ 任意 id 可跨校改他人资料/重置密码(账号接管)。
|
||||
// 关键防线:目标用户本身必须与数据范围有交集。伪造任意 id 可跨校改他人资料/重置密码(账号接管),
|
||||
// 由 requireUserInScope(id) 拦截——它与 request.groupId 无关,是编辑越权的唯一有效锚点。
|
||||
accessScopeService.requireUserInScope(id);
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
// groupId 对编辑无业务语义(SysUserService.edit 不读取它,仅按 schoolId+roleIds 落身份),
|
||||
// 且 DTO 与前端契约均声明「groupId 仅 Create 校验、编辑不提交」。故为空时不再拦截(修复「组ID不能为空」);
|
||||
// 仅当前端确实带了组上下文时,才二次校验该组落在数据范围内,保留防御纵深。
|
||||
if (request.getGroupId() != null) {
|
||||
accessScopeService.requireGroupInScope(request.getGroupId());
|
||||
}
|
||||
sysUserService.edit(id, request);
|
||||
}
|
||||
|
||||
@@ -160,7 +168,8 @@ public class SysUserController {
|
||||
|
||||
/**
|
||||
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。<br>
|
||||
* 「用户身份」回显唯一来源(roles 列已停写,仅历史兼容读取);编辑请求提交时原样携带 {schoolId, roleIds}。
|
||||
* 「用户身份」回显唯一来源(数据源=身份行 sys_user_role_rel;roles 列已停写、不再读取);
|
||||
* 编辑请求提交时原样携带 {schoolId, roleIds}。
|
||||
*/
|
||||
@GetMapping("UserIdentity")
|
||||
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
|
||||
@@ -197,4 +206,22 @@ public class SysUserController {
|
||||
requireQueryGroupInScope(groupId);
|
||||
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.userbase.config.Config;
|
||||
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.service.IAppCategoryService;
|
||||
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.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -33,6 +37,12 @@ public class AppController {
|
||||
@Autowired
|
||||
private IApplicationInfoService applicationInfoService;
|
||||
|
||||
@Autowired
|
||||
private IGroupAuthzService groupAuthzService;
|
||||
|
||||
@Autowired
|
||||
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
|
||||
|
||||
/**
|
||||
* 按主键 ID 查询应用信息(带缓存)。<br>
|
||||
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
|
||||
@@ -43,7 +53,53 @@ public class AppController {
|
||||
*/
|
||||
@GetMapping("{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) {
|
||||
return null;
|
||||
}
|
||||
@@ -55,20 +111,81 @@ public class AppController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。<br>
|
||||
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
|
||||
* 超级管理员返回全部;不透出 appSecret。<br>
|
||||
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
|
||||
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
|
||||
* 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)}
|
||||
* ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部;
|
||||
* 不透出 appSecret。<br>
|
||||
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
|
||||
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
|
||||
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
|
||||
*
|
||||
* @param userId 用户ID(必填)
|
||||
* @param clientType 客户端类型 key(可选)
|
||||
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
|
||||
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
|
||||
*/
|
||||
@GetMapping("list")
|
||||
public List<BoaAppType> list(@RequestParam("userId") Integer userId,
|
||||
@RequestParam(value = "clientType", required = false) Integer clientType,
|
||||
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
|
||||
ClientEnum clientEnum = (clientType == null) ? null
|
||||
: IEnum.getByKey(ClientEnum.class, clientType);
|
||||
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.listBoaAppType(userId, clientEnum);
|
||||
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,19 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
/** Legacy paths retained; naked phone/account/openid authority is retired. */
|
||||
@RestController @RequestMapping("/oapi/channel")
|
||||
public class ChannelBindController {
|
||||
private final ObjectProvider<MobileIdentityService> provider;
|
||||
public ChannelBindController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
|
||||
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
|
||||
public static class Bind {public String channelProof,userProof,operationId;}
|
||||
public static class Unbind {public String userActionProof,reauthProof,operationId;}
|
||||
@GetMapping("/user") public Map<String,Object> user(@RequestHeader("X-YH-Proof") String proof){return service().resolve(proof);}
|
||||
@PostMapping("/bind") public Map<String,Object> bind(@RequestBody Bind b){return service().bind(b.channelProof,b.userProof,b.operationId);}
|
||||
@PostMapping("/bindByPhone") public void phone(){Contexts.requireCaller("channel.bind.phone");throw new AuthFailure(403,"METHOD_DISABLED");}
|
||||
@PostMapping("/unbind") public Map<String,Object> unbind(@RequestBody Unbind b){return service().unbind(b.userActionProof,b.reauthProof,b.operationId);}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.utils.model.BoaGroup;
|
||||
import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
|
||||
@@ -47,7 +48,7 @@ public class GroupController {
|
||||
* 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。
|
||||
*
|
||||
* @param groupId 组ID(必填)
|
||||
* @param withSub 是否展开子节点,缺省 false
|
||||
* @param withSub 是否递归加上子节点,缺省 false
|
||||
* @return 组节点;不存在返回 null
|
||||
*/
|
||||
@GetMapping("byId")
|
||||
@@ -69,6 +70,25 @@ public class GroupController {
|
||||
return sysGroupService.getGroupList(ids, withSub);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取组内用户(不分页,一次返回全部):<br>
|
||||
* withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);<br>
|
||||
* roleIds 逗号分隔(复用 SysRoleEnum 键),按身份行 {@code sys_user_role_rel}(正常态)过滤,缺省不过滤。<br>
|
||||
* 返回裸 {@code List<BoaUser>}(轻量字段 id/account/name/type),不套信封、无分页元数据。<br>
|
||||
* 注意:轻量 BoaUser 不填 {@code status}/组内转出标记,需判禁用或转出请走 {@code /oapi/user/byId}。
|
||||
*
|
||||
* @param groupId 组ID(必填)
|
||||
* @param withSub 是否递归子组,缺省 false
|
||||
* @param roleIds 角色过滤(逗号分隔),缺省不过滤
|
||||
* @return 组内用户列表(按用户去重;无匹配返回空列表)
|
||||
*/
|
||||
@GetMapping("users")
|
||||
public List<BoaUser> groupUsers(@RequestParam("groupId") Integer groupId,
|
||||
@RequestParam(value = "withSub", required = false) Boolean withSub,
|
||||
@RequestParam(value = "roleIds", required = false) String roleIds) {
|
||||
return sysGroupService.listGroupUsers(groupId, withSub, roleIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增组:返回创建后的组节点(含 id)。<br>
|
||||
* 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
|
||||
|
||||
@@ -1,93 +1,16 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.utils.exception.DException;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import com.yunhai.userbase.common.context.CallerAppContext;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.model.identity.IdentitySwitchOapiRequest;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
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;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 用户身份开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/identity/...})。<br>
|
||||
* <br>
|
||||
* 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName),
|
||||
* 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。<br>
|
||||
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;<br>
|
||||
* <b>切换(switch)</b>额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门),
|
||||
* 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。<br>
|
||||
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/27
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "identity/")
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.Map;
|
||||
@RestController @RequestMapping("/oapi/identity")
|
||||
public class IdentityController {
|
||||
|
||||
@Autowired
|
||||
private IdentityService identityService;
|
||||
|
||||
/** 允许调用 switch 的应用 appKey 白名单(逗号分隔,缺省空 = 禁闭) */
|
||||
@Value("${yh.identity.switch-allowed-app-keys:}")
|
||||
private String switchAllowedAppKeys;
|
||||
|
||||
/**
|
||||
* 查询用户当前激活身份:优先 sys_user_current_role,未切换时兜底主身份(动态解析);
|
||||
* 无任何身份返回 null。
|
||||
*/
|
||||
@GetMapping("current")
|
||||
public BoaRoleInfo current(@RequestParam("userId") Integer userId) {
|
||||
return identityService.getCurrent(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户全部可选身份(正常态),按 主身份优先 → sortNo → id 排序,供「切换身份」页渲染。
|
||||
*/
|
||||
@GetMapping("available")
|
||||
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
|
||||
return identityService.listAvailable(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换身份:AppKey 白名单校验后,校验三元组合法并 UPSERT sys_user_current_role。
|
||||
*/
|
||||
@PostMapping("switch")
|
||||
public BoaRoleInfo switchIdentity(@RequestBody @Validated IdentitySwitchOapiRequest request) {
|
||||
checkSwitchAllowed();
|
||||
return identityService.switchIdentity(request.getUserId(), request.getRoleId(), request.getSchoolId());
|
||||
}
|
||||
|
||||
/** AppKey 级闸门:仅允许白名单内的应用替用户切换身份。 */
|
||||
private void checkSwitchAllowed() {
|
||||
Integer callerAppKey = CallerAppContext.getAppKey();
|
||||
if (callerAppKey == null || !parseAllowedAppKeys().contains(callerAppKey)) {
|
||||
throw new DException("当前应用无权调用身份切换接口");
|
||||
}
|
||||
}
|
||||
|
||||
private Set<Integer> parseAllowedAppKeys() {
|
||||
if (switchAllowedAppKeys == null || switchAllowedAppKeys.trim().isEmpty()) {
|
||||
return new HashSet<>();
|
||||
}
|
||||
return Arrays.stream(switchAllowedAppKeys.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(Integer::valueOf)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
private final ObjectProvider<MobileIdentityService> provider;
|
||||
public IdentityController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
|
||||
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
|
||||
@GetMapping("/current") public Map<String,Object> current(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,false);}
|
||||
@GetMapping("/available") public Map<String,Object> available(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,true);}
|
||||
public static class Switch {public String userActionProof,operationId;public int roleId,schoolId;public long expectedIdentityVersion;}
|
||||
@PostMapping("/switch") public Map<String,Object> change(@RequestBody Switch b){return service().switchIdentity(b.userActionProof,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthFilter;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.mobile.MobileIdentityService;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import javax.servlet.http.*;
|
||||
import java.util.*;
|
||||
|
||||
/** Existing app-sign authentication; explicit portal allowlist. Never log request/credentials. */
|
||||
@RestController
|
||||
@RequestMapping("/oapi/user")
|
||||
public class PasswordVerificationController {
|
||||
private final ObjectProvider<MobileIdentityService> services;
|
||||
private final String env; private final Set<String> portals;
|
||||
public PasswordVerificationController(ObjectProvider<MobileIdentityService> services,
|
||||
@Value("${yh.mobile.env:}") String env,
|
||||
@Value("${yh.mobile.password-allowed-app-keys:}") String allowed) {
|
||||
this.services=services;this.env=env;this.portals=new HashSet<>(Arrays.asList(allowed.replace(" ","").split(",")));
|
||||
}
|
||||
public static class Credentials { public String account,password; }
|
||||
@PostMapping("/verifyPassword")
|
||||
public SessionAuthenticator.Snapshot verify(@RequestBody Credentials b,HttpServletRequest r,HttpServletResponse response) {
|
||||
response.setHeader("Cache-Control","no-store");
|
||||
Object app=r.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
|
||||
AuthFailure.require(app!=null,401,"APP_SIGNATURE_REQUIRED");
|
||||
AuthFailure.require(portals.contains(String.valueOf(app)),403,"PORTAL_CALLER_REQUIRED");
|
||||
MobileIdentityService service=services.getIfAvailable();AuthFailure.require(service!=null,503,"AUTHORITY_UNAVAILABLE");
|
||||
return service.verifyPortalPassword(env,b.account,b.password);
|
||||
}
|
||||
@ExceptionHandler(AuthFailure.class)
|
||||
public ResponseEntity<Map<String,Object>> failure(AuthFailure e) {
|
||||
Map<String,Object> result=new LinkedHashMap<>();result.put("code",e.status==401?40101:e.status==403?40301:50301);result.put("msg",e.status==401?"账号或密码错误":e.code);
|
||||
return ResponseEntity.status("APP_SIGNATURE_REQUIRED".equals(e.code)||"PORTAL_CALLER_REQUIRED".equals(e.code)?e.status:200).header("Cache-Control","no-store").body(result);
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
package com.yunhai.userbase.controller.oapi;
|
||||
|
||||
import com.yunhai.platform.appauth.AppAuthProperties;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.repository.ApplicationInfoRepository;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 验签兜底端点:供其他业务服务在「共享 Redis 宕机且本地缓存也未命中」时直连获取某应用的密钥。<br><br>
|
||||
* 直接从 MySQL 读(绕过可能同宕的共享 Redis),是密钥的权威源;写入侧仍由
|
||||
* {@code SignKeyCacheService} 同步到 Redis。返回形状与 BasePlatform 读侧解析契约一致:<br>
|
||||
* {@code {"appKey":"100616","secret":"...","enabled":true}}。<br><br>
|
||||
* 解析优先级:<br>
|
||||
* 1) 本库 application_info(覆盖全部「已颁发」应用的实时密钥);<br>
|
||||
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps}(平台自身默认密钥,如 应用中心 100616,
|
||||
* 即便尚未落库也可解析,避免「默认 appKey 未建行」时此处返回 404 导致验签链路失败);<br>
|
||||
* 两者皆无 → 404。<br><br>
|
||||
* 注意:此端点返回的是应用密钥,必须仅内网可达(由部署网络隔离保障),切勿暴露到公网。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping(Config.OPEN_API_PATH + "internal")
|
||||
public class SignKeyInternalController {
|
||||
|
||||
private final ApplicationInfoRepository applicationInfoRepository;
|
||||
private final AppAuthProperties appAuthProperties;
|
||||
|
||||
public SignKeyInternalController(ApplicationInfoRepository applicationInfoRepository,
|
||||
AppAuthProperties appAuthProperties) {
|
||||
this.applicationInfoRepository = applicationInfoRepository;
|
||||
this.appAuthProperties = appAuthProperties;
|
||||
}
|
||||
|
||||
@GetMapping("sign-key/{appId}")
|
||||
public ResponseEntity<Map<String, Object>> signKey(@PathVariable String appId) {
|
||||
Integer key;
|
||||
try {
|
||||
key = Integer.parseInt(appId.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
ApplicationInfo info = applicationInfoRepository.findByAppKey(key);
|
||||
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
|
||||
return ok(info.getAppKey() == null ? appId : String.valueOf(info.getAppKey()),
|
||||
info.getAppSecret());
|
||||
}
|
||||
// 库内无此应用密钥:回退到配置式受信应用(平台自身默认密钥 100616 等),
|
||||
// 避免「应用中心默认 appKey 未落库」时兜底查询返回 404。
|
||||
String cfgSecret = appAuthProperties.getTrustedApps().get(appId);
|
||||
if (cfgSecret != null && !cfgSecret.trim().isEmpty()) {
|
||||
return ok(appId, cfgSecret);
|
||||
}
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
private static ResponseEntity<Map<String, Object>> ok(String appKey, String secret) {
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("appKey", appKey);
|
||||
body.put("secret", secret);
|
||||
body.put("enabled", true);
|
||||
return ResponseEntity.ok(body);
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,10 @@ import com.yunhai.platform.utils.model.BoaUser;
|
||||
import com.yunhai.userbase.common.annotation.OapiLog;
|
||||
import com.yunhai.userbase.common.validation.ValidationGroups;
|
||||
import com.yunhai.userbase.config.Config;
|
||||
import com.yunhai.userbase.entity.ApplicationInfo;
|
||||
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
|
||||
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
|
||||
@@ -90,30 +92,49 @@ public class UserController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||
* 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
|
||||
* <br>
|
||||
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
|
||||
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
|
||||
* 已保证非空);<br>
|
||||
* ② 查 application_info 映射到具体 appId;<br>
|
||||
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
|
||||
* ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
|
||||
* 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
|
||||
* 口径同现有兜底);<br>
|
||||
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
|
||||
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
|
||||
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
|
||||
* <br>
|
||||
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br>
|
||||
* 边界:应用解析失败(appCode/appKey 查不到对应应用)→ 直接返回空列表(安全优先,禁止意外
|
||||
* 返回其他应用的模块)。<br>
|
||||
* 记账:本参数暴露面=模块名/路径/图标级低敏数据;如需收紧可后续限定仅 BASE 应用调用
|
||||
* (暂放开,表单中心建设方案 §6.2 已记)。参数用 appCode(字符串)而非整数 appKey:
|
||||
* 跨环境稳定,与 form-center 分发路由 /app/{appCode}/ 同键。
|
||||
*
|
||||
* @param request 用于取 AppAuthFilter 设置的 appKey
|
||||
* @param request 用于取 AppAuthFilter 设置的 appKey(未传 appCode 时使用)
|
||||
* @param userId 用户ID(必填)
|
||||
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
|
||||
* 为空时取用户全部真实归属组,与权限一览一致)
|
||||
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
|
||||
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
|
||||
*/
|
||||
@GetMapping("modules")
|
||||
public List<BoaModule> modules(HttpServletRequest request,
|
||||
@RequestParam("userId") Integer userId,
|
||||
@RequestParam(value = "schId", required = false) Integer schId) {
|
||||
@RequestParam(value = "schId", required = false) Integer schId,
|
||||
@RequestParam(value = "appCode", required = false) String appCode) {
|
||||
if (userId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Integer appId = resolveCallerAppId(request);
|
||||
Integer appId;
|
||||
if (appCode != null && !appCode.trim().isEmpty()) {
|
||||
ApplicationInfo app = applicationInfoService.getByAppCode(appCode.trim());
|
||||
if (app == null) {
|
||||
log.warn("[oapi:user:modules] appCode={} not found in application_info, uri={}",
|
||||
appCode, request.getRequestURI());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
appId = app.getId();
|
||||
} else {
|
||||
appId = resolveCallerAppId(request);
|
||||
}
|
||||
if (appId == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
@@ -165,12 +186,15 @@ public class UserController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
|
||||
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。<br>
|
||||
* 用户身份按<b>追加语义</b>写({@code roleIds} 已存在则不变、不存在则补,绝不删除):开放接口调用方
|
||||
* 只能表达"该用户应有某身份"这层意图,覆盖语义会误删该校其它身份;学校范围取 {@code schoolId},
|
||||
* 缺省按 {@code groupId} 解析。管理端编辑仍是该校全量覆盖(见 {@code /sysuser/Edit/{id}})。
|
||||
*/
|
||||
@PostMapping("edit/{id}")
|
||||
public BoaUser edit(@PathVariable Integer id,
|
||||
@RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
|
||||
sysUserService.edit(id, request);
|
||||
sysUserService.editWithIdentityAppend(id, request);
|
||||
return userCenterService.getUserInfoWithDeptById(id);
|
||||
}
|
||||
|
||||
@@ -237,6 +261,19 @@ public class UserController {
|
||||
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>
|
||||
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
|
||||
|
||||
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
|
||||
|
||||
/**
|
||||
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
|
||||
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
|
||||
*/
|
||||
@NotBlank
|
||||
@Column(name = "custom_id", nullable = false, length = 64)
|
||||
@Column(name = "custom_id", nullable = true, length = 64)
|
||||
private String customId;
|
||||
|
||||
/**
|
||||
@@ -66,6 +66,13 @@ public class AppModule extends BaseEntity {
|
||||
@Column(name = "path", length = 255)
|
||||
private String path;
|
||||
|
||||
/**
|
||||
* 模块图标(YschIcons 图标名,如 "notice-list")。非必填;<br>
|
||||
* 为空时开放接口 {@code BoaModule.icon} 即为 null(不回退应用图标,由消费方自行兜底)。<br>
|
||||
*/
|
||||
@Column(name = "icon", length = 128)
|
||||
private String icon;
|
||||
|
||||
/**
|
||||
* 层级:根=1,子=父+1,上限5(由 Service 校验)。<br>
|
||||
*/
|
||||
|
||||
@@ -26,6 +26,16 @@ import java.util.List;
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用名称(页面*必填)
|
||||
*/
|
||||
@@ -52,7 +62,7 @@ public class ApplicationInfo extends BaseEntity {
|
||||
* appKey 系统生成唯一标识,整型,从100000起自增。<br>
|
||||
*/
|
||||
@NotNull
|
||||
@Column(name = "app_key", unique = true, nullable = false)
|
||||
@Column(name = "app_key", unique = true, nullable = false, updatable = false)
|
||||
private Integer appKey;
|
||||
|
||||
/**
|
||||
@@ -62,6 +72,19 @@ public class ApplicationInfo extends BaseEntity {
|
||||
@Column(name = "app_secret", nullable = false, length = 128)
|
||||
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)
|
||||
@Column(name = "clients", columnDefinition = "json")
|
||||
@@ -91,12 +114,31 @@ public class ApplicationInfo extends BaseEntity {
|
||||
@Column(name = "app_icon")
|
||||
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")
|
||||
private String officialAccountUrl;
|
||||
|
||||
/**
|
||||
* 微信小程序应用地址
|
||||
*/
|
||||
@Column(name = "miniprogram_url")
|
||||
private String miniprogramUrl;
|
||||
|
||||
/**
|
||||
* 钉钉应用地址
|
||||
*/
|
||||
@@ -142,4 +184,4 @@ public class ApplicationInfo extends BaseEntity {
|
||||
@Column(name = "push_secret")
|
||||
private String pushSecret;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.entity;
|
||||
import javax.persistence.*;
|
||||
|
||||
/** Permanent credential-key tombstone. Application deletion must never delete this row. */
|
||||
@Entity @Table(name="application_key_reservation")
|
||||
public class ApplicationKeyReservation {
|
||||
/**
|
||||
* 历史身份审计未完成的机械门禁哨兵(sql/20261026_application_source_principal_draft.sql §Gate):
|
||||
* 该行存在=禁止发放任何新 appKey。取 -1 而非 0:默认 sql_mode 下字面 0 会被
|
||||
* AUTO_INCREMENT 改写为下一个生成值,-1 按字面落库且低于保留段,即使被返回也会被
|
||||
* allocateAppKey 的 key<100000 熔断拒绝。
|
||||
*/
|
||||
public static final Integer GATE_CLOSED_SENTINEL = -1;
|
||||
@Id @GeneratedValue(strategy=GenerationType.IDENTITY)
|
||||
@Column(name="app_key")
|
||||
private Integer appKey;
|
||||
public Integer getAppKey(){return appKey;}
|
||||
}
|
||||
@@ -33,6 +33,21 @@ public class SysGroup extends BaseEntity {
|
||||
|
||||
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)
|
||||
|
||||
@@ -114,6 +114,12 @@ public class SysOapiLog implements Serializable {
|
||||
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||
private byte[] responseData;
|
||||
|
||||
/**
|
||||
* 响应原文(GZIP 前)UTF-8 字节数,写入时算好,列表按此列切换在线预览/下载,避免加载 response_data 大字段。
|
||||
*/
|
||||
@Column(name = "response_size")
|
||||
private Integer responseSize;
|
||||
|
||||
/**
|
||||
* HTTP状态码
|
||||
*/
|
||||
|
||||
@@ -106,6 +106,12 @@ public class SysOperationLog implements Serializable {
|
||||
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
|
||||
private byte[] responseData;
|
||||
|
||||
/**
|
||||
* 响应原文(GZIP 前)UTF-8 字节数,写入时算好,列表按此列切换在线预览/下载,避免加载 response_data 大字段。
|
||||
*/
|
||||
@Column(name = "response_size")
|
||||
private Integer responseSize;
|
||||
|
||||
/**
|
||||
* HTTP状态码
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,9 @@ import javax.validation.constraints.NotNull;
|
||||
* @author chenx
|
||||
* @date 2026/7/27
|
||||
* @description 用户-业务角色关系表(逻辑外键,无物理 FK)。<br>
|
||||
* 一个用户可绑定多个业务角色;一个角色也可绑定多个用户。<br>
|
||||
* 一行 = 用户 u 在组 g 下拥有角色 r;一个用户可在多个组下分别绑定角色,同一组下同一角色唯一。<br>
|
||||
* 组维度(2026-09-16):groupId = 绑定发生时所选的组(角色授权页添加用户弹窗左侧树选中组 / 用户编辑弹窗所在组),
|
||||
* NULL = 历史数据视为全局生效(代码侧兜底,不参与按组过滤的排除)。
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
@Data
|
||||
@@ -24,10 +26,11 @@ import javax.validation.constraints.NotNull;
|
||||
name = "sys_user_app_role_rel",
|
||||
indexes = {
|
||||
@Index(name = "idx_user_app_role_user", columnList = "user_id"),
|
||||
@Index(name = "idx_user_app_role_role", columnList = "role_id")
|
||||
@Index(name = "idx_user_app_role_role", columnList = "role_id"),
|
||||
@Index(name = "idx_user_app_role_group", columnList = "group_id")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_user_app_role", columnNames = {"user_id", "role_id"})
|
||||
@UniqueConstraint(name = "uk_user_app_role_group", columnNames = {"user_id", "role_id", "group_id"})
|
||||
}
|
||||
)
|
||||
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
|
||||
@@ -46,4 +49,10 @@ public class SysUserAppRoleRel extends BaseEntity {
|
||||
@NotNull
|
||||
@Column(name = "role_id", nullable = false)
|
||||
private Integer roleId;
|
||||
|
||||
/**
|
||||
* 绑定发生时所选的组ID(逻辑外键 → SysGroup.id);NULL = 历史数据视为全局。<br>
|
||||
*/
|
||||
@Column(name = "group_id")
|
||||
private Integer groupId;
|
||||
}
|
||||
|
||||
@@ -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,29 @@
|
||||
package com.yunhai.userbase.event;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
|
||||
/**
|
||||
* 应用授权域数据变更事件:组应用开通(sys_group_app_rel)、组模块授权(sys_group_module_perm)、
|
||||
* 业务角色及其模块/用户绑定(sys_app_role / sys_app_role_module_rel / sys_user_app_role_rel)、
|
||||
* 应用分类(app_category)、模块(app_module)、学校别名覆盖(sys_school_app_override /
|
||||
* sys_school_category_override)任意写操作事务提交后发布。<br>
|
||||
* <b>消费方</b>:{@code CacheInvalidationListener} 整清 {@code userAppList}
|
||||
* (/oapi/app/list 的组开通/角色授权输入面广,无法从事件反推受影响用户,按「宁可失效宽泛」原则整缓存 clear)。<br>
|
||||
* <b>宽泛口径说明</b>:部分发布哨口(组模块授权、学校别名覆盖)经查不影响 /oapi/app/list 当前返回,
|
||||
* 按失效总原则仍纳入——多清无害,防未来读链演化漏驱。<br>
|
||||
* <b>粒度</b>:单一事件类型——所有发布方语义均为「应用授权面变了,重建列表」,不做精确到用户/组的最小化裁剪。
|
||||
*/
|
||||
public class AppAuthzChangedEvent extends ApplicationEvent {
|
||||
|
||||
/** 触发本次变更的写口(assignAppsToGroup/saveRoleModules/editCategory 等),仅用于日志定位。 */
|
||||
private final String sourceOp;
|
||||
|
||||
public AppAuthzChangedEvent(Object source, String sourceOp) {
|
||||
super(source);
|
||||
this.sourceOp = sourceOp;
|
||||
}
|
||||
|
||||
public String getSourceOp() {
|
||||
return sourceOp;
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ public class OapiMetricsController {
|
||||
vo.setDays(d);
|
||||
vo.setTop(n);
|
||||
|
||||
Object[] total = repository.aggregateTotal(since, 0);
|
||||
Object[] total = firstRow(repository.aggregateTotal(since, 0));
|
||||
vo.setTotalCnt(asLong(total, 0));
|
||||
vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1)));
|
||||
vo.setTotalErrCnt(asLong(total, 2));
|
||||
@@ -82,7 +82,7 @@ public class OapiMetricsController {
|
||||
}
|
||||
|
||||
// 压测流量合计(单列口径,不与线上热度混算)
|
||||
Object[] lt = repository.aggregateTotal(since, 1);
|
||||
Object[] lt = firstRow(repository.aggregateTotal(since, 1));
|
||||
vo.setLoadTestCnt(asLong(lt, 0));
|
||||
return vo;
|
||||
}
|
||||
@@ -97,6 +97,17 @@ public class OapiMetricsController {
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构
|
||||
* (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。
|
||||
*/
|
||||
private Object[] firstRow(Object[] arr) {
|
||||
if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) {
|
||||
return (Object[]) arr[0];
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
private long asLong(Object[] arr, int i) {
|
||||
if (arr == null || i >= arr.length || arr[i] == null) {
|
||||
return 0L;
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.yunhai.userbase.mobile;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import com.yunhai.userbase.entity.SysUser;
|
||||
import com.yunhai.userbase.enums.StatusEnum;
|
||||
import com.yunhai.userbase.repository.SysUserRepository;
|
||||
import com.yunhai.userbase.service.impl.IdentityService;
|
||||
import com.yunhai.platform.utils.model.BoaRoleInfo;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import java.time.Clock;
|
||||
@Configuration(proxyBeanMethods=false)
|
||||
@ConditionalOnProperty(name="yh.mobile.identity-store-enabled",havingValue="true")
|
||||
public class MobileIdentityConfiguration {
|
||||
@Bean public MobileIdentityService mobileIdentityService(JdbcTemplate db,PlatformTransactionManager manager,
|
||||
SysUserRepository users,PasswordEncoder encoder,IdentityService identities,com.yunhai.userbase.service.IGroupAuthzService authorization) {
|
||||
MobileIdentityService.Accounts accounts=new MobileIdentityService.Accounts() {
|
||||
public boolean applicationAllowed(int uid,Integer school,String audience){
|
||||
if(school==null||school<0||audience==null||!audience.matches("[1-9][0-9]{0,8}"))return false;
|
||||
if(school==0&&!users.findById(uid).map(u->u.getType()==com.yunhai.userbase.enums.UserTypeEnum.SUPER_ADMIN).orElse(false))return false;
|
||||
return authorization.findUserAuthorizedAppIds(uid,school).contains(Integer.valueOf(audience));
|
||||
}
|
||||
public int casAccount(String principal) {
|
||||
AuthFailure.require(users.countByAccount(principal)==1,403,"CAS_USER_UNMAPPED_OR_AMBIGUOUS");
|
||||
SysUser u=users.findByAccount(principal);return u.getId();
|
||||
}
|
||||
public int verifyPassword(String account,String password) {
|
||||
AuthFailure.require(account!=null&&password!=null,401,"PROOF_INVALID");SysUser user=users.findByAccount(account);
|
||||
AuthFailure.require(user!=null&&encoder.matches(password,user.getPassword())&&user.getDisabled()!=StatusEnum.DISABLED&&!Boolean.TRUE.equals(user.getExpired()),401,"PROOF_INVALID");
|
||||
return user.getId();
|
||||
}
|
||||
public SessionAuthenticator.Snapshot snapshot(int uid) {
|
||||
SysUser u=users.findById(uid).orElseThrow(()->new AuthFailure(401,"USER_REVOKED"));BoaRoleInfo role=identities.getCurrentForAuthentication(uid);
|
||||
boolean qualified=role!=null&&identities.listAvailable(uid).stream().anyMatch(r->java.util.Objects.equals(r.getSchId(),role.getSchId())&&java.util.Objects.equals(r.getRoleId(),role.getRoleId()));
|
||||
return new SessionAuthenticator.Snapshot(uid,qualified?role.getSchId():null,qualified?role.getRoleId():null,
|
||||
u.getDisabled()!=StatusEnum.DISABLED&&!Boolean.TRUE.equals(u.getExpired()),u.getType()==com.yunhai.userbase.enums.UserTypeEnum.SUPER_ADMIN,true,0,0,0,0,u.getAccount(),u.getRealName());
|
||||
}
|
||||
public java.util.List<BoaRoleInfo> available(int uid){return identities.listAvailable(uid);}
|
||||
public void switchIdentity(int uid,int role,int school) { identities.switchIdentity(uid,role,school); }
|
||||
};
|
||||
return new MobileIdentityService(db,new TransactionTemplate(manager),accounts,Clock.systemUTC());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.yunhai.userbase.mobile;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import java.time.Clock;
|
||||
import java.util.*;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/** UB-owned scope/proof/ledger state. Constructor collaborators permit isolated DB contract tests. */
|
||||
public final class MobileIdentityService {
|
||||
public interface Accounts {
|
||||
int verifyPassword(String account,String password);
|
||||
SessionAuthenticator.Snapshot snapshot(int userId);
|
||||
void switchIdentity(int userId,int roleId,int schoolId);
|
||||
default java.util.List<com.yunhai.platform.utils.model.BoaRoleInfo> available(int uid){throw new AuthFailure(503,"IDENTITY_UNAVAILABLE");}
|
||||
default int casAccount(String principal){throw new AuthFailure(503,"CAS_MAPPING_NOT_CONFIGURED");}
|
||||
default boolean applicationAllowed(int userId,Integer school,String audience){return false;}
|
||||
}
|
||||
private final JdbcTemplate db;private final TransactionTemplate tx;private final Accounts accounts;private final Clock clock;
|
||||
private final ObjectMapper json=new ObjectMapper();
|
||||
public MobileIdentityService(JdbcTemplate db,TransactionTemplate tx,Accounts accounts,Clock clock) {
|
||||
this.db=db;this.tx=tx;this.accounts=accounts;this.clock=clock;
|
||||
}
|
||||
private CallerAppContext caller(String cap) { return Contexts.requireCaller(cap); }
|
||||
private void scope(CallerAppContext c,String cap,String providerScope) {
|
||||
Integer count=db.queryForObject("SELECT COUNT(*) FROM mob_auth_caller_scope WHERE env=? AND app_id=? AND capability=? AND provider_scope=?",
|
||||
Integer.class,c.env,c.appId,cap,providerScope);
|
||||
AuthFailure.require(count!=null&&count==1,403,"CALLER_CAPABILITY_DENIED");
|
||||
}
|
||||
public String channelProof(String flow,String providerScope,String subjectHash,long version,String action) {
|
||||
CallerAppContext c=caller("proof.channel.register");scope(c,"proof.channel.register",providerScope);
|
||||
AuthFailure.require(subjectHash!=null&&subjectHash.matches("[a-f0-9]{64}")&&Arrays.asList("RESOLVE","BIND").contains(action),400,"PROOF_INVALID");
|
||||
long current=subjectVersion(c.env,providerScope,subjectHash);AuthFailure.require(current==version,409,"SUBJECT_STALE");
|
||||
return issue(c,action,flow,null,providerScope,subjectHash,version);
|
||||
}
|
||||
public String passwordProof(String channelHandle,String account,String password) {
|
||||
CallerAppContext c=caller("proof.user.issue");Map<String,Object> ch=proof(c,channelHandle,"BIND",false);
|
||||
int uid=accounts.verifyPassword(account,password);AuthFailure.require(uid>0,401,"PROOF_INVALID");
|
||||
return issue(c,"BIND_USER",str(ch,"flow_id"),uid,str(ch,"provider_scope"),str(ch,"provider_subject_hash"),number(ch,"subject_version"));
|
||||
}
|
||||
public String actionProof(int userId,String flow,String providerScope,String subject,long version,String action) {
|
||||
CallerAppContext c=caller("proof.action.register");scope(c,"proof.action.register",providerScope);
|
||||
AuthFailure.require(Arrays.asList("UNBIND","SWITCH","READ_CURRENT","READ_AVAILABLE").contains(action),400,"PROOF_INVALID");
|
||||
AuthFailure.require(accounts.snapshot(userId).active,401,"USER_REVOKED");
|
||||
if(!"SWITCH".equals(action)&&!action.startsWith("READ_")) AuthFailure.require(boundUser(c.env,providerScope,subject)==userId,403,"NOT_BINDING_OWNER");
|
||||
return issue(c,action,flow,userId,providerScope,subject,version);
|
||||
}
|
||||
// This endpoint does not accept a claimed userId: it issues a proof ONLY for the credential-verified user.
|
||||
public String reauth(String actionHandle,String account,String password) {
|
||||
CallerAppContext c=caller("proof.user.issue");Map<String,Object> a=proof(c,actionHandle,"UNBIND",false);
|
||||
int uid=accounts.verifyPassword(account,password);AuthFailure.require(uid==number(a,"user_id"),403,"PROOF_SUBJECT_MISMATCH");
|
||||
return issue(c,"UNBIND_REAUTH",str(a,"flow_id"),uid,str(a,"provider_scope"),str(a,"provider_subject_hash"),number(a,"subject_version"));
|
||||
}
|
||||
public Map<String,Object> resolve(String handle) {
|
||||
CallerAppContext c=caller("channel.resolve");return tx.execute(s->{Map<String,Object> p=proof(c,handle,"RESOLVE",true);
|
||||
scope(c,"channel.resolve",str(p,"provider_scope"));return result(boundUser(c.env,str(p,"provider_scope"),str(p,"provider_subject_hash")));});
|
||||
}
|
||||
public Map<String,Object> bind(String channel,String user,String operationId) {
|
||||
CallerAppContext c=caller("channel.bind.password");String digest=Crypto.hash("BIND\n"+Crypto.hash(channel)+"\n"+Crypto.hash(user));
|
||||
return operation(c,operationId,digest,()-> {
|
||||
Map<String,Object> ch=proof(c,channel,"BIND",false),p=proof(c,user,"BIND_USER",false);int uid=(int)number(p,"user_id");
|
||||
requireSameFlow(ch,p);scope(c,"channel.bind.password",str(ch,"provider_scope"));
|
||||
lockUser(uid);AuthFailure.require(accounts.snapshot(uid).active,401,"USER_REVOKED");
|
||||
String providerScope=str(ch,"provider_scope"),subject=str(ch,"provider_subject_hash");long expected=number(ch,"subject_version");
|
||||
AuthFailure.require(subjectVersion(c.env,providerScope,subject)==expected,409,"SUBJECT_STALE");
|
||||
int owner=boundUser(c.env,providerScope,subject);AuthFailure.require(owner==0||owner==uid,409,"BINDING_CONFLICT");
|
||||
if(expected==0) db.update("INSERT INTO mob_auth_subject(env,provider_scope,provider_subject_hash,user_id,version) VALUES(?,?,?,?,1)",c.env,providerScope,subject,uid);
|
||||
else db.update("UPDATE mob_auth_subject SET user_id=?,version=version+1 WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND version=?",uid,c.env,providerScope,subject,expected);
|
||||
consume(c,channel,"BIND");consume(c,user,"BIND_USER");ensureEpoch(c.env,uid);return result(uid);
|
||||
});
|
||||
}
|
||||
public Map<String,Object> unbind(String action,String reauth,String operationId) {
|
||||
CallerAppContext c=caller("channel.unbind.self");return operation(c,operationId,Crypto.hash("UNBIND\n"+Crypto.hash(action)+"\n"+Crypto.hash(reauth)),()-> {
|
||||
Map<String,Object> p=proof(c,action,"UNBIND",false),r=proof(c,reauth,"UNBIND_REAUTH",false);requireSameFlow(p,r);
|
||||
int uid=(int)number(p,"user_id");AuthFailure.require(uid==number(r,"user_id"),403,"PROOF_SUBJECT_MISMATCH");lockUser(uid);
|
||||
String providerScope=str(p,"provider_scope"),subject=str(p,"provider_subject_hash");scope(c,"channel.unbind.self",providerScope);
|
||||
int changed=db.update("UPDATE mob_auth_subject SET user_id=NULL,version=version+1 WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND user_id=? AND version=?",
|
||||
c.env,providerScope,subject,uid,number(p,"subject_version"));AuthFailure.require(changed==1,409,"SUBJECT_STALE");
|
||||
ensureEpoch(c.env,uid);db.update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE env=? AND user_id=?",c.env,uid);
|
||||
consume(c,action,"UNBIND");consume(c,reauth,"UNBIND_REAUTH");return result(uid);
|
||||
});
|
||||
}
|
||||
public Map<String,Object> switchIdentity(String handle,int role,int school,long expected,String op) {
|
||||
CallerAppContext c=caller("identity.switch.self");return operation(c,op,Crypto.hash(Crypto.hash(handle)+"\n"+role+"\n"+school+"\n"+expected),()-> {
|
||||
Map<String,Object> p=proof(c,handle,"SWITCH",false);int uid=(int)number(p,"user_id");lockUser(uid);ensureEpoch(c.env,uid);
|
||||
long current=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,uid);
|
||||
AuthFailure.require(current==expected,409,"CONTEXT_STALE");AuthFailure.require(school>0||(school==0&&accounts.snapshot(uid).superAdmin),403,"SCHOOL_CONTEXT_REQUIRED");
|
||||
accounts.switchIdentity(uid,role,school);
|
||||
long updated=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,uid);
|
||||
if(updated==current){db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env=? AND user_id=?",c.env,uid);updated=current+1;}
|
||||
AuthFailure.require(updated>current,409,"CONTEXT_STALE");
|
||||
consume(c,handle,"SWITCH");Map<String,Object> r=result(uid);r.put("identityVersion",updated);return r;
|
||||
});
|
||||
}
|
||||
public Map<String,Object> readIdentity(String handle,boolean available) {
|
||||
CallerAppContext c=caller("identity.read.self");return tx.execute(s->{Map<String,Object> p=proof(c,handle,available?"READ_AVAILABLE":"READ_CURRENT",true);
|
||||
SessionAuthenticator.Snapshot a=accounts.snapshot((int)number(p,"user_id"));Map<String,Object> r=result(a.userId);if(available)r.put("roles",accounts.available(a.userId));r.put("schId",a.schoolId);r.put("roleId",a.roleId);return r;});
|
||||
}
|
||||
public Map<String,Object> recover(String op,String hash) {
|
||||
CallerAppContext c=caller("mobile.operation.read");return existing(c,op,hash);
|
||||
}
|
||||
public SessionAuthenticator.Snapshot validate(SessionAuthenticator.Session s) {
|
||||
CallerAppContext c=caller("mobile.identity.validate");AuthFailure.require(c.env.equals(s.env),401,"ENV_MISMATCH");
|
||||
SessionAuthenticator.Snapshot user=accounts.snapshot(s.userId);
|
||||
AuthFailure.require(s.audience!=null&&(s.audience.equals(c.appId)||accounts.applicationAllowed(s.userId,user.schoolId,s.audience)),403,"APPLICATION_DENIED");
|
||||
Long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,c.env);
|
||||
Map<String,Object> epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",c.env,s.userId);
|
||||
long binding=0;boolean bound="h5".equals(s.channel);
|
||||
if(!bound) {
|
||||
String scope,subject;
|
||||
try{com.fasterxml.jackson.databind.JsonNode n=json.readTree(s.subject);AuthFailure.require(n.isArray()&&n.size()==2,401,"CONTEXT_STALE");scope=n.get(0).asText();subject=n.get(1).asText();}catch(Exception e){throw new AuthFailure(401,"CONTEXT_STALE");}
|
||||
List<Map<String,Object>> rows=db.queryForList("SELECT version FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND user_id=?",c.env,scope,subject,s.userId);
|
||||
AuthFailure.require(rows.size()==1,401,"CONTEXT_STALE");binding=number(rows.get(0),"version");bound=true;}
|
||||
return new SessionAuthenticator.Snapshot(user.userId,user.schoolId,user.roleId,user.active,user.superAdmin,bound,
|
||||
number(epoch,"user_epoch"),number(epoch,"identity_version"),binding,generation,user.account,user.name);
|
||||
}
|
||||
/** Only called by the app-signed, portal-allowlisted password endpoint. No channel proof required. */
|
||||
public SessionAuthenticator.Snapshot verifyPortalPassword(String env,String account,String password) {
|
||||
AuthFailure.require(env!=null&&!env.trim().isEmpty(),503,"CONFIG_MISMATCH");
|
||||
AuthFailure.require(account!=null&&!account.trim().isEmpty()&&account.length()<=256&&password!=null&&!password.isEmpty()&&password.length()<=1024,401,"CREDENTIALS_INVALID");
|
||||
int uid=accounts.verifyPassword(account,password);
|
||||
return tx.execute(status->{
|
||||
lockUser(uid);ensureEpoch(env,uid);SessionAuthenticator.Snapshot u=accounts.snapshot(uid);
|
||||
AuthFailure.require(u.active&&u.schoolId!=null&&(u.schoolId>0||(u.schoolId==0&&u.superAdmin))&&u.roleId!=null,403,"IDENTITY_DENIED");
|
||||
Map<String,Object> epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",env,uid);
|
||||
long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,env);
|
||||
return new SessionAuthenticator.Snapshot(uid,u.schoolId,u.roleId,true,u.superAdmin,true,number(epoch,"user_epoch"),number(epoch,"identity_version"),0,generation,u.account,u.name);
|
||||
});
|
||||
}
|
||||
public SessionAuthenticator.Snapshot resolveCas(String principal) {
|
||||
CallerAppContext c=caller("mobile.cas.resolve");
|
||||
AuthFailure.require(principal!=null&&!principal.trim().isEmpty()&&principal.length()<=256,400,"CAS_PRINCIPAL_INVALID");
|
||||
return tx.execute(status->{
|
||||
int uid=accounts.casAccount(principal);AuthFailure.require(uid>0,403,"CAS_USER_UNMAPPED");
|
||||
lockUser(uid);ensureEpoch(c.env,uid);SessionAuthenticator.Snapshot u=accounts.snapshot(uid);
|
||||
AuthFailure.require(u.active&&u.schoolId!=null&&(u.schoolId>0||(u.schoolId==0&&u.superAdmin))&&u.roleId!=null,403,"IDENTITY_DENIED");
|
||||
Map<String,Object> epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",c.env,uid);
|
||||
long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,c.env);
|
||||
return new SessionAuthenticator.Snapshot(uid,u.schoolId,u.roleId,true,u.superAdmin,true,number(epoch,"user_epoch"),number(epoch,"identity_version"),0,generation,u.account,u.name);
|
||||
});
|
||||
}
|
||||
/** The signed caller attests a LIVE portal session. The browser never supplies this envelope. */
|
||||
private SessionAuthenticator.Snapshot requirePortalSession(SessionAuthenticator.Session s) {
|
||||
CallerAppContext c=caller("mobile.identity.validate");
|
||||
AuthFailure.require(s!=null&&s.parentHash==null&&c.appId.equals(s.audience),403,"PORTAL_SESSION_REQUIRED");
|
||||
SessionAuthenticator.Snapshot a=validate(s);
|
||||
AuthFailure.require(s.absoluteExpiresAt>clock.instant().getEpochSecond()&&a.active&&a.bindingActive&&a.roleId!=null
|
||||
&&a.schoolId!=null&&(a.schoolId>0||(a.schoolId==0&&a.superAdmin))&&a.userEpoch==s.userEpoch
|
||||
&&a.bindingVersion==s.bindingVersion&&a.generation==s.generation&&a.identityVersion>=s.identityVersion,401,"CONTEXT_STALE");
|
||||
return a;
|
||||
}
|
||||
public Object readPortalIdentity(SessionAuthenticator.Session session,boolean available) {
|
||||
caller("mobile.identity.read");SessionAuthenticator.Snapshot a=requirePortalSession(session);
|
||||
if(available)return accounts.available(a.userId);
|
||||
return accounts.available(a.userId).stream().filter(r->Objects.equals(r.getRoleId(),a.roleId)&&Objects.equals(r.getSchId(),a.schoolId))
|
||||
.findFirst().orElseThrow(()->new AuthFailure(403,"IDENTITY_DENIED"));
|
||||
}
|
||||
public Map<String,Object> switchPortalIdentity(SessionAuthenticator.Session session,int role,int school,long expected,String op) {
|
||||
CallerAppContext c=caller("mobile.identity.switch");
|
||||
requirePortalSession(session);
|
||||
String hash=Crypto.hash("PORTAL_SWITCH\n"+session.id+"\n"+session.userId+"\n"+role+"\n"+school+"\n"+expected);
|
||||
return operation(c,op,hash,()->{
|
||||
lockUser(session.userId);requirePortalSession(session);
|
||||
long current=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,session.userId);
|
||||
AuthFailure.require(current==expected,409,"CONTEXT_STALE");
|
||||
AuthFailure.require(school>0||(school==0&&accounts.snapshot(session.userId).superAdmin),403,"SCHOOL_CONTEXT_REQUIRED");
|
||||
AuthFailure.require(accounts.available(session.userId).stream().anyMatch(r->Objects.equals(r.getRoleId(),role)&&Objects.equals(r.getSchId(),school)),403,"IDENTITY_DENIED");
|
||||
accounts.switchIdentity(session.userId,role,school);
|
||||
long updated=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,session.userId);
|
||||
if(updated==current){db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env=? AND user_id=?",c.env,session.userId);updated++;}
|
||||
AuthFailure.require(updated>current,409,"CONTEXT_STALE");
|
||||
Map<String,Object> result=result(session.userId);result.put("identityVersion",updated);result.put("roleId",role);result.put("schoolId",school);result.put("previousIdentityVersion",current);result.put("sessionRef",Crypto.hash(session.id));result.put("committedAt",clock.instant().getEpochSecond());return result;
|
||||
});
|
||||
}
|
||||
public Map<String,Object> queryBindings(int actor,int offset,int size){
|
||||
CallerAppContext c=caller("mobile.binding.read");SessionAuthenticator.Snapshot admin=accounts.snapshot(actor);
|
||||
AuthFailure.require(admin.active&&admin.superAdmin,403,"ADMIN_REQUIRED");AuthFailure.require(offset>=0&&size>0&&size<=100,400,"PAGE_INVALID");
|
||||
List<Map<String,Object>> rows=db.queryForList("SELECT user_id,provider_scope,version FROM mob_auth_subject WHERE env=? AND user_id IS NOT NULL ORDER BY user_id,provider_scope LIMIT ? OFFSET ?",c.env,size,offset);
|
||||
List<Map<String,Object>> masked=new ArrayList<>();for(Map<String,Object> row:rows){Map<String,Object> item=new LinkedHashMap<>();item.put("userRef","u***"+(number(row,"user_id")%1000));item.put("providerScope",str(row,"provider_scope"));item.put("bindingVersion",number(row,"version"));masked.add(item);}
|
||||
Map<String,Object> result=new LinkedHashMap<>();result.put("list",masked);result.put("count",db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject WHERE env=? AND user_id IS NOT NULL",Long.class,c.env));result.put("authority","mob_auth_subject");result.put("legacyMigrationVerified",false);return result;
|
||||
}
|
||||
private Map<String,Object> operation(CallerAppContext c,String op,String hash,Supplier<Map<String,Object>> work) {
|
||||
AuthFailure.require(op!=null&&op.matches("[A-Za-z0-9_-]{22,128}"),400,"IDEMPOTENCY_KEY_REQUIRED");
|
||||
try { return tx.execute(status->{
|
||||
// Shared database lock, not a JVM lock. Serializes this caller's idempotency namespace across nodes.
|
||||
AuthFailure.require(!db.queryForList("SELECT app_id FROM mob_auth_caller WHERE env=? AND app_id=? FOR UPDATE",c.env,c.appId).isEmpty(),403,"CALLER_CAPABILITY_DENIED");
|
||||
Map<String,Object> saved=existing(c,op,hash);if(saved!=null)return saved;
|
||||
Map<String,Object> value=work.get();String encoded;
|
||||
try{encoded=json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}
|
||||
db.update("INSERT INTO mob_auth_operation(env,caller_id,operation_id,request_hash,result_json) VALUES(?,?,?,?,?)",c.env,c.appId,op,hash,encoded);return value;
|
||||
}); } catch(DataIntegrityViolationException e) {throw new AuthFailure(409,"BINDING_CONFLICT");}
|
||||
}
|
||||
private Map<String,Object> existing(CallerAppContext c,String op,String hash) {
|
||||
List<Map<String,Object>> rows=db.queryForList("SELECT request_hash,result_json FROM mob_auth_operation WHERE env=? AND caller_id=? AND operation_id=?",c.env,c.appId,op);
|
||||
if(rows.isEmpty())return null;AuthFailure.require(Crypto.equal(hash,str(rows.get(0),"request_hash")),409,"IDEMPOTENCY_CONFLICT");
|
||||
try{return json.readValue(str(rows.get(0),"result_json"),LinkedHashMap.class);}catch(Exception e){throw new AuthFailure(503,"LEDGER_UNAVAILABLE");}
|
||||
}
|
||||
private String issue(CallerAppContext c,String action,String flow,Integer user,String providerScope,String subject,long version) {
|
||||
AuthFailure.require(flow!=null&&flow.length()<=128,400,"FLOW_INVALID");String raw=Crypto.random();
|
||||
db.update("INSERT INTO mob_auth_proof(handle_hash,env,caller_id,action,flow_id,user_id,provider_scope,provider_subject_hash,subject_version,expires_at,user_epoch,consumed) VALUES(?,?,?,?,?,?,?,?,?,?,?,FALSE)",
|
||||
Crypto.hash(raw),c.env,c.appId,action,flow,user,providerScope,subject,version,clock.instant().getEpochSecond()+120,user==null?0:epoch(c.env,user));return raw;
|
||||
}
|
||||
private Map<String,Object> proof(CallerAppContext c,String raw,String action,boolean consume) {
|
||||
AuthFailure.require(raw!=null,401,"PROOF_INVALID");List<Map<String,Object>> rows=db.queryForList("SELECT * FROM mob_auth_proof WHERE handle_hash=?",Crypto.hash(raw));
|
||||
AuthFailure.require(rows.size()==1,401,"PROOF_INVALID");Map<String,Object> p=rows.get(0);
|
||||
AuthFailure.require(c.env.equals(str(p,"env"))&&c.appId.equals(str(p,"caller_id"))&&action.equals(str(p,"action"))
|
||||
&& !Boolean.TRUE.equals(p.get("consumed"))&&!Boolean.TRUE.equals(p.get("CONSUMED"))&&number(p,"expires_at")>clock.instant().getEpochSecond(),401,"PROOF_INVALID");
|
||||
if(get(p,"user_id")!=null){int uid=(int)number(p,"user_id");AuthFailure.require(accounts.snapshot(uid).active&&epoch(c.env,uid)==number(p,"user_epoch"),401,"PROOF_INVALID");}
|
||||
if(consume) consume(c,raw,action);return p;
|
||||
}
|
||||
private void consume(CallerAppContext c,String raw,String action) {
|
||||
int n=db.update("UPDATE mob_auth_proof SET consumed=TRUE WHERE handle_hash=? AND env=? AND caller_id=? AND action=? AND consumed=FALSE AND expires_at>?",
|
||||
Crypto.hash(raw),c.env,c.appId,action,clock.instant().getEpochSecond());AuthFailure.require(n==1,401,"PROOF_INVALID");
|
||||
}
|
||||
private long subjectVersion(String env,String providerScope,String subject) {
|
||||
List<Long> rows=db.queryForList("SELECT version FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=?",Long.class,env,providerScope,subject);return rows.isEmpty()?0:rows.get(0);
|
||||
}
|
||||
private int boundUser(String env,String providerScope,String subject) {
|
||||
List<Integer> rows=db.queryForList("SELECT user_id FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=?",Integer.class,env,providerScope,subject);return rows.isEmpty()||rows.get(0)==null?0:rows.get(0);
|
||||
}
|
||||
private void lockUser(int uid) {AuthFailure.require(!db.queryForList("SELECT id FROM sys_user WHERE id=? FOR UPDATE",uid).isEmpty(),401,"USER_REVOKED");}
|
||||
private void ensureEpoch(String env,int uid) { if(db.queryForObject("SELECT COUNT(*) FROM mob_auth_epoch WHERE env=? AND user_id=?",Integer.class,env,uid)==0)
|
||||
db.update("INSERT INTO mob_auth_epoch(env,user_id,user_epoch,identity_version) VALUES(?,?,1,1)",env,uid); }
|
||||
private long epoch(String env,int uid){List<Long> rows=db.queryForList("SELECT user_epoch FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,env,uid);return rows.isEmpty()?1:rows.get(0);}
|
||||
private static void requireSameFlow(Map<String,Object> a,Map<String,Object> b) {
|
||||
AuthFailure.require(str(a,"flow_id").equals(str(b,"flow_id"))&&Objects.equals(a.get("provider_scope"),b.get("provider_scope"))
|
||||
&&Objects.equals(a.get("provider_subject_hash"),b.get("provider_subject_hash")),401,"PROOF_INVALID");
|
||||
}
|
||||
private static Map<String,Object> result(int uid) {Map<String,Object> r=new LinkedHashMap<>();r.put("userId",uid);r.put("status","COMMITTED");return r;}
|
||||
private static Object get(Map<String,Object> p,String k) {return p.containsKey(k)?p.get(k):p.get(k.toUpperCase(Locale.ROOT));}
|
||||
private static String str(Map<String,Object> p,String k) {Object v=get(p,k);return v==null?null:String.valueOf(v);}
|
||||
private static long number(Map<String,Object> p,String k) {return ((Number)get(p,k)).longValue();}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yunhai.userbase.mobile;
|
||||
import com.yunhai.platform.mobile.*;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
@RestController @RequestMapping("/oapi/mobile")
|
||||
public class MobileProofController {
|
||||
private final ObjectProvider<MobileIdentityService> provider;
|
||||
public MobileProofController(ObjectProvider<MobileIdentityService> provider) { this.provider=provider; }
|
||||
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
|
||||
public static class Channel {public String flowId,subjectHash,action;public ProviderScope scope;public long subjectVersion;}
|
||||
public static class User {public String channelProof,actionProof,account,password;}
|
||||
public static class Action {public int userId;public String flowId,subjectHash,action;public ProviderScope scope;public long subjectVersion;}
|
||||
public static class Operation {public String operationId,requestHash;}
|
||||
public static class PortalIdentity {public SessionAuthenticator.Session session;public boolean available;public int roleId,schoolId;public long expectedIdentityVersion;public String operationId;}
|
||||
@PostMapping("/identity/read") public Object readPortal(@RequestBody PortalIdentity b){return service().readPortalIdentity(b.session,b.available);}
|
||||
@PostMapping("/identity/switch") public Object switchPortal(@RequestBody PortalIdentity b){return service().switchPortalIdentity(b.session,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);}
|
||||
public static class Cas {public String issuer,principal;}
|
||||
public static class BindingQuery {public int actor,offset,size=20;}
|
||||
@PostMapping("/bindings/query") public Object bindings(@RequestBody BindingQuery b){return service().queryBindings(b.actor,b.offset,b.size);}
|
||||
@PostMapping("/cas/resolve") public void cas(@RequestBody Cas b){throw new AuthFailure(410,"CAS_RETIRED");}
|
||||
@PostMapping("/identity/validate") public SessionAuthenticator.Snapshot validate(@RequestBody SessionAuthenticator.Session b){return service().validate(b);}
|
||||
@PostMapping("/proofs/channel") public Map<String,String> channel(@RequestBody Channel b){AuthFailure.require(b.scope!=null,400,"PROVIDER_SCOPE_INVALID");return Collections.singletonMap("proofHandle",service().channelProof(b.flowId,b.scope.key(),b.subjectHash,b.subjectVersion,b.action));}
|
||||
@PostMapping("/proofs/user") public Map<String,String> user(@RequestBody User b){return Collections.singletonMap("proofHandle",b.actionProof!=null?service().reauth(b.actionProof,b.account,b.password):service().passwordProof(b.channelProof,b.account,b.password));}
|
||||
@PostMapping("/proofs/action") public Map<String,String> action(@RequestBody Action b){AuthFailure.require(b.scope!=null,400,"PROVIDER_SCOPE_INVALID");return Collections.singletonMap("proofHandle",service().actionProof(b.userId,b.flowId,b.scope.key(),b.subjectHash,b.subjectVersion,b.action));}
|
||||
@PostMapping("/operations/result") public Map<String,Object> result(@RequestBody Operation b){return service().recover(b.operationId,b.requestHash);}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yunhai.userbase.mobile;
|
||||
import com.yunhai.platform.mobile.AuthFailure;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import java.util.*;
|
||||
@RestControllerAdvice @Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class MobileSecurityAdvice {
|
||||
@ExceptionHandler(org.springframework.dao.DataAccessException.class)
|
||||
public ResponseEntity<Map<String,Object>> unavailable(org.springframework.dao.DataAccessException e) {
|
||||
return failure(new AuthFailure(503,"AUTHORITY_UNAVAILABLE"));
|
||||
}
|
||||
@ExceptionHandler(AuthFailure.class) public ResponseEntity<Map<String,Object>> failure(AuthFailure e) {
|
||||
Map<String,Object> dto=new LinkedHashMap<>();dto.put("code",e.code);dto.put("message",e.code);dto.put("data",null);
|
||||
return ResponseEntity.status(e.status).header("Cache-Control","no-store").body(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.yunhai.userbase.mobile;
|
||||
import com.yunhai.platform.mobile.AuthFailure;
|
||||
import com.yunhai.userbase.event.*;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/** Synchronous events join the writer's transaction; never rely on AFTER_COMMIT invalidation. */
|
||||
@Component @ConditionalOnProperty(name="yh.mobile.identity-store-enabled",havingValue="true")
|
||||
public class MobileVersionListener {
|
||||
private final JdbcTemplate db;public MobileVersionListener(JdbcTemplate db){this.db=db;}
|
||||
private void transactional(){if(!TransactionSynchronizationManager.isActualTransactionActive()){
|
||||
db.update("UPDATE mob_auth_generation SET ready=FALSE");throw new AuthFailure(503,"VERSION_WRITE_OUTSIDE_TRANSACTION");}}
|
||||
@EventListener public void user(UserChangedEvent event){transactional();for(Integer uid:event.getUserIds())
|
||||
db.update(event.getSource() instanceof com.yunhai.userbase.service.impl.IdentityService ? "UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE user_id=?" : "UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE user_id=?",uid);}
|
||||
@EventListener public void group(GroupChangedEvent event){transactional();db.update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1");}
|
||||
}
|
||||
@@ -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.NotNull;
|
||||
import javax.validation.constraints.Size;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,22 @@ public class ApplicationInfoRequest {
|
||||
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空")
|
||||
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>
|
||||
*/
|
||||
@@ -64,11 +81,26 @@ public class ApplicationInfoRequest {
|
||||
*/
|
||||
private String appIcon;
|
||||
|
||||
/**
|
||||
* 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。<br>
|
||||
*/
|
||||
private String iconKey;
|
||||
|
||||
/**
|
||||
* 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。<br>
|
||||
*/
|
||||
private String iconColor;
|
||||
|
||||
/**
|
||||
* 公众号应用地址。<br>
|
||||
*/
|
||||
private String officialAccountUrl;
|
||||
|
||||
/**
|
||||
* 微信小程序应用地址。<br>
|
||||
*/
|
||||
private String miniprogramUrl;
|
||||
|
||||
/**
|
||||
* 钉钉应用地址。<br>
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,9 @@ import java.util.List;
|
||||
*/
|
||||
@Data
|
||||
public class ApplicationInfoVo {
|
||||
/** Stable, non-secret application incarnation used for source isolation. */
|
||||
private String sourcePrincipalId;
|
||||
|
||||
/**
|
||||
* 主键ID。<br>
|
||||
*/
|
||||
@@ -47,6 +50,16 @@ public class ApplicationInfoVo {
|
||||
*/
|
||||
private String appSecret;
|
||||
|
||||
/**
|
||||
* 技术标识(= projman app.name,如 notice)。<br>
|
||||
*/
|
||||
private String appCode;
|
||||
|
||||
/**
|
||||
* 应用性质:BASE=基石 / BUSINESS=业务。<br>
|
||||
*/
|
||||
private String appNature;
|
||||
|
||||
/**
|
||||
* 应用前端类型列表。<br>
|
||||
*/
|
||||
@@ -72,11 +85,26 @@ public class ApplicationInfoVo {
|
||||
*/
|
||||
private String appIcon;
|
||||
|
||||
/**
|
||||
* 矢量图标键(AppIcon 资产库图标名);空 = 未配置。<br>
|
||||
*/
|
||||
private String iconKey;
|
||||
|
||||
/**
|
||||
* 矢量图标着色(CSS 色值);空 = 端侧默认。<br>
|
||||
*/
|
||||
private String iconColor;
|
||||
|
||||
/**
|
||||
* 公众号应用地址。<br>
|
||||
*/
|
||||
private String officialAccountUrl;
|
||||
|
||||
/**
|
||||
* 微信小程序应用地址。<br>
|
||||
*/
|
||||
private String miniprogramUrl;
|
||||
|
||||
/**
|
||||
* 钉钉应用地址。<br>
|
||||
*/
|
||||
|
||||
@@ -26,11 +26,17 @@ public class AppModuleRequest {
|
||||
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
|
||||
private String name;
|
||||
|
||||
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空")
|
||||
/**
|
||||
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
|
||||
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
|
||||
*/
|
||||
private String customId;
|
||||
|
||||
/** 模块路径(前端路由/资源路径)。<br> */
|
||||
/** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
|
||||
private String path;
|
||||
|
||||
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
|
||||
private String icon;
|
||||
|
||||
private Integer sortNo;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ public class AppModuleVo {
|
||||
private String name;
|
||||
private String customId;
|
||||
private String path;
|
||||
private String icon;
|
||||
private Integer level;
|
||||
private Integer sortNo;
|
||||
/** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */
|
||||
|
||||
@@ -29,6 +29,13 @@ public class AppRoleAuthzAppVo {
|
||||
/** 排序号。 */
|
||||
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<>();
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ package com.yunhai.userbase.model.approle;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 角色绑定用户保存入参(全量覆盖,角色维度)。<br>
|
||||
* userIds 为该角色本次绑定的用户ID全量列表;传空列表即解绑该角色全部用户。<br>
|
||||
* 角色绑定用户保存入参(增量添加,角色维度)。<br>
|
||||
* bindings 为本次新增的「用户×组」绑定明细(组 = 弹窗左侧树当前选中组);已存在的 (userId, groupId) 跳过。<br>
|
||||
* 组维度改造(2026-09-16)后不再全量覆盖:覆盖会把其他组下的既有绑定一并删掉。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
@@ -21,7 +23,7 @@ public class AppRoleUserBindRequest {
|
||||
private Integer roleId;
|
||||
|
||||
/**
|
||||
* 绑定的用户ID全量列表。<br>
|
||||
* 本次新增的「用户×组」绑定明细。<br>
|
||||
*/
|
||||
private List<Integer> userIds;
|
||||
private List<@Valid AppRoleUserBinding> bindings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yunhai.userbase.model.approle;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* 角色绑定用户明细项:一行 = 用户在指定组下的绑定。<br>
|
||||
* groupId = 添加用户弹窗左侧树当前选中组(绑定发生时的组),部门列展示时按组ID实时拼路径。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
@Data
|
||||
public class AppRoleUserBinding {
|
||||
|
||||
/** 用户ID。 */
|
||||
@NotNull(message = "用户ID不能为空")
|
||||
private Integer userId;
|
||||
|
||||
/** 绑定发生时所选的组ID。 */
|
||||
@NotNull(message = "组ID不能为空")
|
||||
private Integer groupId;
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package com.yunhai.userbase.model.approle;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色已绑定用户列表项视图。<br>
|
||||
* 角色已绑定用户列表项视图(一行 = 一条「用户×组」绑定)。<br>
|
||||
* groupPath 为查询时按 groupId 实时拼接的组路径(不落库),scope 非空时按查看者数据范围裁剪——
|
||||
* 学校管理员只看到本校路径(如 /云海小学/教师组),超管看全路径。
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
@@ -18,4 +20,10 @@ public class AppRoleUserVo {
|
||||
|
||||
/** 真实姓名。 */
|
||||
private String realName;
|
||||
|
||||
/** 绑定发生时所选的组ID。 */
|
||||
private Integer groupId;
|
||||
|
||||
/** 组路径(查询时按 groupId 现算,scope 裁剪后)。 */
|
||||
private String groupPath;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户绑定角色保存入参(全量覆盖,用户维度)。<br>
|
||||
* roleIds 为该用户本次绑定的角色ID全量列表;传空列表即解绑该用户全部角色。<br>
|
||||
* 用户绑定角色保存入参(按组覆盖,用户维度)。<br>
|
||||
* roleIds 为该用户在 groupId 组下的角色ID全量列表;传空列表即解绑该用户在该组下的全部角色,
|
||||
* 不影响其在其他组的绑定;groupId 为空时兜底主组(历史调用方兼容)。<br>
|
||||
*
|
||||
* @author chenx
|
||||
*/
|
||||
@@ -21,7 +22,12 @@ public class UserAppRoleBindRequest {
|
||||
private Integer userId;
|
||||
|
||||
/**
|
||||
* 绑定的角色ID全量列表。<br>
|
||||
* 绑定组ID(可选):角色归属的组,空则兜底该用户主组。<br>
|
||||
*/
|
||||
private Integer groupId;
|
||||
|
||||
/**
|
||||
* 该组下绑定的角色ID全量列表。<br>
|
||||
*/
|
||||
private List<Integer> roleIds;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 应用级勾选态:<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。 */
|
||||
private List<GroupAuthzModuleVo> modules = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -32,7 +32,9 @@ public class GroupModuleAuthzItem {
|
||||
/**
|
||||
* 模块ID;传 0 表示「该应用下全部模块」。<br>
|
||||
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br>
|
||||
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。
|
||||
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。<br>
|
||||
* 特殊情形:应用下<b>一个模块都没有</b>时,改写成 -appId 的整应用哨兵记录
|
||||
* (见 {@code AppLevelModuleId}),保证这类应用也能被授权与回显。
|
||||
*/
|
||||
@NotNull(message = "模块ID不能为空(整应用传 0)")
|
||||
private Integer moduleId;
|
||||
|
||||
@@ -38,8 +38,6 @@ public class SysOapiLogListVo {
|
||||
private String operationDesc;
|
||||
/** 请求参数JSON(脱敏后) */
|
||||
private String requestParams;
|
||||
/** 响应结果(GZIP 解压后原文,脱敏后;随列表带出,详情弹窗直接展示) */
|
||||
private String responseData;
|
||||
/** HTTP 状态码 */
|
||||
private Integer statusCode;
|
||||
/** 耗时(ms) */
|
||||
@@ -52,6 +50,8 @@ public class SysOapiLogListVo {
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
/** 响应原文字节数(用于前端按大小切换在线预览/下载,response_data 原文不进列表) */
|
||||
private Integer responseSize;
|
||||
|
||||
/**
|
||||
* 全参构造,顺序需与 {@code SysOapiLog} 的列表投影字段一致。<br>
|
||||
@@ -61,7 +61,8 @@ public class SysOapiLogListVo {
|
||||
String requestIp, String requestUri, String requestMethod,
|
||||
String operationType, String classMethod, String operationDesc,
|
||||
String requestParams, Integer statusCode, Long costTime,
|
||||
String userAgent, String errorMsg, LocalDateTime createTime) {
|
||||
String userAgent, String errorMsg, LocalDateTime createTime,
|
||||
Integer responseSize) {
|
||||
this.id = id;
|
||||
this.logLevel = logLevel;
|
||||
this.callerAppKey = callerAppKey;
|
||||
@@ -78,5 +79,6 @@ public class SysOapiLogListVo {
|
||||
this.userAgent = userAgent;
|
||||
this.errorMsg = errorMsg;
|
||||
this.createTime = createTime;
|
||||
this.responseSize = responseSize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ public class SysOperationLogListVo {
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createTime;
|
||||
/** 响应原文字节数(用于前端按大小切换在线预览/下载,response_data 原文不进列表) */
|
||||
private Integer responseSize;
|
||||
|
||||
/**
|
||||
* 全参构造,顺序需与 {@code SysOperationLog} 的列表投影字段一致。<br>
|
||||
@@ -57,7 +59,7 @@ public class SysOperationLogListVo {
|
||||
String requestIp, String requestUri, String requestMethod,
|
||||
String operationType, String classMethod, String requestParams,
|
||||
Integer statusCode, Long costTime, String userAgent,
|
||||
String errorMsg, LocalDateTime createTime) {
|
||||
String errorMsg, LocalDateTime createTime, Integer responseSize) {
|
||||
this.id = id;
|
||||
this.logLevel = logLevel;
|
||||
this.userId = userId;
|
||||
@@ -73,5 +75,6 @@ public class SysOperationLogListVo {
|
||||
this.userAgent = userAgent;
|
||||
this.errorMsg = errorMsg;
|
||||
this.createTime = createTime;
|
||||
this.responseSize = responseSize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,10 @@ public class PushRecordEntity {
|
||||
@Column(name = "payload", columnDefinition = "MEDIUMTEXT")
|
||||
private String payload;
|
||||
|
||||
/** 请求体 UTF-8 字节数(写入时算好,列表按此列切换在线预览/下载,避免查询 payload 大字段)。 */
|
||||
@Column(name = "payload_size")
|
||||
private Integer payloadSize;
|
||||
|
||||
@Column(name = "sign_t")
|
||||
private String signT;
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ public class PushRecordListVo {
|
||||
/** 创建时间 */
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
/** 请求体字节数(用于前端按大小切换在线预览/下载,payload 原文不进列表) */
|
||||
private Integer payloadSize;
|
||||
|
||||
/**
|
||||
* 全参构造,顺序需与 {@code PushRecordEntity} 的列表投影字段一致。<br>
|
||||
@@ -54,7 +56,7 @@ public class PushRecordListVo {
|
||||
public PushRecordListVo(Long id, Integer subscriberAppKey, String appName, String interfacePath,
|
||||
String interfaceLabel, String requestUrl, String signT, Integer httpStatus,
|
||||
Integer respCode, String respMsg, Integer success, Integer attemptNo,
|
||||
Integer costMs, String triggerType, LocalDateTime createdAt) {
|
||||
Integer costMs, String triggerType, LocalDateTime createdAt, Integer payloadSize) {
|
||||
this.id = id;
|
||||
this.subscriberAppKey = subscriberAppKey;
|
||||
this.appName = appName;
|
||||
@@ -70,5 +72,6 @@ public class PushRecordListVo {
|
||||
this.costMs = costMs;
|
||||
this.triggerType = triggerType;
|
||||
this.createdAt = createdAt;
|
||||
this.payloadSize = payloadSize;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import lombok.Data;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 测试推送请求(测试推送页调用)。
|
||||
* 手动触发推送请求(应用推送设置的组/用户全量同步调用)。
|
||||
*
|
||||
* @author chenx
|
||||
* @date 2026/8/14
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.util.List;
|
||||
* <ul>
|
||||
* <li>status:0正常 / 2禁用(由 userbase StatusEnum 映射)</li>
|
||||
* <li>groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)</li>
|
||||
* <li>roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)</li>
|
||||
* <li>roleList:用户身份,含 roleId / schId(schId = 身份行所属学校,即 sys_user_role_rel.schoolId)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author chenx
|
||||
@@ -47,7 +47,7 @@ public class PushUserDTO {
|
||||
@Data
|
||||
public static class PushUserRoleRef {
|
||||
private Integer roleId;
|
||||
/** 所属学校 id(userbase 无角色-学校关联,统一取用户主组所属学校) */
|
||||
/** 身份行所属学校 id(sys_user_role_rel.schoolId,学校组 / 区域组 / 根直属各自的实际组 id) */
|
||||
private Integer schId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 Integer type;
|
||||
private Integer sort;
|
||||
/** 启停状态(2026-10-20 部门线 P0):0=启用 / 1=停用;BeanUtils 同名拷贝,null 视为 0 由前端兜底 */
|
||||
private Integer status;
|
||||
/** 包含子组(2026-10-21):1=部门列表含下级部门 / 0=仅本级;BeanUtils 同名拷贝,null 视为 1 由前端兜底 */
|
||||
private Integer includeChildren;
|
||||
/** 直属成员数(部门表格「人员」列);口径同 oapi 部门目录:sys_user_group_rel 按 groupId 计数,不排除转出。 */
|
||||
private Long memberCount;
|
||||
/**
|
||||
* 直属成员姓名预览,最多 {@code SysGroupService.MEMBER_PREVIEW_LIMIT} 个。<br>
|
||||
* 完整名单不走这里(部门可能上百人),前端点「查看全部」调 /api/sysuser/List?groupId=xxx。
|
||||
*/
|
||||
private List<String> memberNames = new ArrayList<>();
|
||||
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
|
||||
private List<SysGroupVo> children = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ public class ExcelSysUser implements Serializable {
|
||||
@Excel(name = "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", orderNum = "8", width = 26)
|
||||
private Integer nature;
|
||||
|
||||
@Excel(name = "用户身份\n(1教师 2教职工 3学生 4家长)", orderNum = "9", width = 24)
|
||||
@Excel(name = "用户身份\n(1教师 2校领导 3学生 4家长)", orderNum = "9", width = 24)
|
||||
private Integer identity;
|
||||
|
||||
@Excel(name = "性别(可选)", orderNum = "10", width = 12)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @author chenx
|
||||
@@ -22,7 +21,6 @@ public class SysUserVo {
|
||||
private String email;
|
||||
private Integer gender;
|
||||
private String profilePicture;
|
||||
private List<Integer> roles;
|
||||
/** 启用状态:StatusEnum 的 key(0=正常/未禁用,1=禁用) */
|
||||
private Integer disabled;
|
||||
/** 用户类型:UserTypeEnum 的 key(0=普通用户,1=超级管理员,2=学校管理员) */
|
||||
@@ -32,7 +30,9 @@ public class SysUserVo {
|
||||
/** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */
|
||||
private Integer groupStatus;
|
||||
/**
|
||||
* 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。<br>
|
||||
* 用户身份名(如「老师、校领导」):列表页按页批量带出(无 N+1)。<br>
|
||||
* 组上下文列表只取「当前组范围解析出的学校集合」内的身份行(与编辑弹窗「所在组」回显同源);
|
||||
* 未选组(全平台视角)才展示全部身份;范围内无身份行时留空。<br>
|
||||
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
|
||||
*/
|
||||
private String identityNames;
|
||||
|
||||
@@ -17,21 +17,25 @@ import java.util.List;
|
||||
* @description
|
||||
*/
|
||||
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);
|
||||
|
||||
boolean existsByName(String name);
|
||||
|
||||
boolean existsByNameAndIdNot(String name, Integer id);
|
||||
|
||||
/**
|
||||
* 查询当前最大的 appKey 值。<br>
|
||||
* 用于新增应用时生成从 100000 起自增的整型 appKey。<br>
|
||||
*
|
||||
* @return 当前最大 appKey,无记录时返回 null
|
||||
*/
|
||||
@Query("SELECT MAX(a.appKey) FROM ApplicationInfo a")
|
||||
Integer findMaxAppKey();
|
||||
// findMaxAppKey 已删(2026-10-26 P2-6):凭据 key 一律由 application_key_reservation 的
|
||||
// insert-only 永久保留行分配(ApplicationInfoService.allocateAppKey),存活表 MAX 不得
|
||||
// 作为分配依据——已删除应用可能占用更高 key,max(存活)+1 会复用注销凭据。
|
||||
|
||||
/**
|
||||
* 按整型 appKey 查单个应用(验签兜底端点直连读取用)。<br>
|
||||
@@ -58,23 +62,39 @@ public interface ApplicationInfoRepository extends JpaRepository<ApplicationInf
|
||||
/**
|
||||
* 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br>
|
||||
* 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br>
|
||||
* 排除用户中心自身的基石应用(appKey=100000)。
|
||||
* 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
|
||||
*
|
||||
* @param categoryId 应用类型ID
|
||||
* @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底
|
||||
*/
|
||||
@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);
|
||||
|
||||
/**
|
||||
* 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br>
|
||||
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br>
|
||||
* 排除用户中心自身的基石应用(appKey=100000)。
|
||||
* 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
|
||||
*
|
||||
* @return 全部应用的授权投影行
|
||||
*/
|
||||
@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();
|
||||
|
||||
/**
|
||||
* 按技术标识查单个应用(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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yunhai.userbase.repository;
|
||||
import com.yunhai.userbase.entity.ApplicationKeyReservation;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
public interface ApplicationKeyReservationRepository extends JpaRepository<ApplicationKeyReservation,Integer> { }
|
||||
@@ -3,7 +3,9 @@ package com.yunhai.userbase.repository;
|
||||
import com.yunhai.userbase.entity.SysGroup;
|
||||
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;
|
||||
@@ -15,6 +17,22 @@ import java.util.Optional;
|
||||
*/
|
||||
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")
|
||||
Integer findMaxSort();
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -20,6 +21,24 @@ public interface SysUserAppRoleRelRepository extends JpaRepository<SysUserAppRol
|
||||
|
||||
List<SysUserAppRoleRel> findByRoleId(Integer roleId);
|
||||
|
||||
/** 指定用户在指定组下的绑定(编辑弹窗按所在组回显/覆盖保存)。 */
|
||||
List<SysUserAppRoleRel> findByUserIdAndGroupId(Integer userId, Integer groupId);
|
||||
|
||||
/** 指定用户无组维度(NULL=历史数据视为全局)的绑定;用户不属于任何组时权限计算只认这些行。 */
|
||||
List<SysUserAppRoleRel> findByUserIdAndGroupIdIsNull(Integer userId);
|
||||
|
||||
/**
|
||||
* 权限计算取数:用户「当前所在组」上的绑定 ∪ NULL(历史数据视为全局)。<br>
|
||||
* groupIds 为空时勿调用(IN () 空集合 SQL 兼容性差),改走 {@link #findByUserIdAndGroupIdIsNull(Integer)}。
|
||||
*/
|
||||
@Query("SELECT r FROM SysUserAppRoleRel r WHERE r.userId = :userId "
|
||||
+ "AND (r.groupId IS NULL OR r.groupId IN :groupIds)")
|
||||
List<SysUserAppRoleRel> findEffectiveByUserId(@Param("userId") Integer userId,
|
||||
@Param("groupIds") Collection<Integer> groupIds);
|
||||
|
||||
/** 判断 (用户, 角色, 组) 绑定是否已存在(增量添加去重)。 */
|
||||
boolean existsByUserIdAndRoleIdAndGroupId(Integer userId, Integer roleId, Integer groupId);
|
||||
|
||||
/**
|
||||
* 按角色ID批量物理删除绑定记录(bulk delete 直接发 SQL)。<br>
|
||||
* 用于「角色绑定用户」全量覆盖式保存的先删后插,以及删除角色时级联清理。<br>
|
||||
@@ -43,9 +62,17 @@ public interface SysUserAppRoleRelRepository extends JpaRepository<SysUserAppRol
|
||||
void deleteByUserIdBulk(@Param("userId") Integer userId);
|
||||
|
||||
/**
|
||||
* 按角色ID + 用户ID 删除单条绑定记录(移除单个用户的角色关联,避免全量重提)。<br>
|
||||
* 按用户ID + 组ID 批量物理删除该组下的全部绑定(编辑弹窗按组覆盖保存的先删后插)。<br>
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId = :roleId AND r.userId = :userId")
|
||||
void deleteByRoleIdAndUserId(@Param("roleId") Integer roleId, @Param("userId") Integer userId);
|
||||
@Query("DELETE FROM SysUserAppRoleRel r WHERE r.userId = :userId AND r.groupId = :groupId")
|
||||
void deleteByUserIdAndGroupIdBulk(@Param("userId") Integer userId, @Param("groupId") Integer groupId);
|
||||
|
||||
/**
|
||||
* 按角色ID + 用户ID + 组ID 删除单条绑定记录(角色用户列表按行移除)。<br>
|
||||
*/
|
||||
@Modifying
|
||||
@Query("DELETE FROM SysUserAppRoleRel r WHERE r.roleId = :roleId AND r.userId = :userId AND r.groupId = :groupId")
|
||||
void deleteByRoleIdAndUserIdAndGroupId(@Param("roleId") Integer roleId, @Param("userId") Integer userId,
|
||||
@Param("groupId") Integer groupId);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user