Compare commits

..
29 Commits
Author SHA1 Message Date
chenxing 6f60d65195 feat(app): 应用稳定来源主体 UUID + /oapi/app/activated 开通判定(FL 授权门禁依赖)
build / build-push (push) Successful in 2m10s
2026-10-28 11:45:41 +08:00
chenxing 809d88c838 feat(dept): SysGroupVo 补 memberCount/memberNames,getTree 回填部门成员数与姓名预览
build / build-push (push) Successful in 2m23s
- SysGroupVo 新增 memberCount(直属成员数)与 memberNames(前 50 个姓名预览)
- SysGroupService 新增 fillMemberStats:整棵树只发两次查询(关联表 in + 用户表 in),
  getTree()/getTree(scope) 均回填,口径同 oapi 部门目录(sys_user_group_rel 按 groupId 计数,不排除转出)
- 预览上限 MEMBER_PREVIEW_LIMIT=50,避免大部门把名单塞进 getTree;完整名单走 /api/sysuser/List?groupId=
2026-10-23 10:30:52 +08:00
chenxing 72fe0f218a feat(app): application_info app_code 写入口守卫(已登记拒改名/拒清空,初次登记唯一性保持)
build / build-push (push) Successful in 1m26s
2026-10-22 17:14:27 +08:00
chenx b1b769a1b8 feat(dept): CUSTOM 自定义部门与统一部门目录契约(双来源部门二期)
build / build-push (push) Successful in 59s
- 三表 ub_custom_dept_category/ub_custom_department/ub_custom_department_member(DDL 入 service/sql,160 待人工执行)
- 实体/仓库/服务:学校隔离(AccessScopeService 新增 requireSchoolInScope/resolveWritableSchoolId)、
  del_marker=id 软删让位 uk、成员=本校教职工黑名单口径(排除学生组 type=8 与禁用账号)
- 管理端 /api/deptcategory、/api/customdept 共 17 端点(含 CandidateUsers 候选口)
- 开放端 /oapi/departments 四口:目录/分类/resolve-batch/成员(scope 缺省=select 严档),
  应用×学校授权走 sys_group_app_rel(仅验签不放行跨校)
- SysGroupService.changeIncludeChildren 补学校组类型校验
- 新增单测 83 例全绿(部门26/分类12/目录25/schoolguard12/includeChildren8)
2026-10-22 09:35:37 +08:00
chenxing bf3f3344c1 feat(dept): sys_group 增列 include_children,支持「包含子组」落库
build / build-push (push) Successful in 1m12s
- 新增 sys_group.include_children(1=部门列表含下级部门 / 0=仅本级,默认 1),仅学校节点使用
- 实体 / Vo / Repository / Service / Controller 全链路打通:单列 UPDATE + 发布 GroupChangedEvent 驱逐 groupSnapshot 缓存
- 新端点 POST /api/sysgroup/SetIncludeChildren/{id},权限只用 requireGroupInScope
  (不能用 requireGroupModifiable:后者禁止 SCHOOL 级操作学校组本身,而本属性正是学校管理员要改的本校属性)
- deploy/sql/20261021_add_sys_group_include_children.sql 提供幂等 DDL,需在 160 手工执行
2026-10-21 15:12:31 +08:00
ops 996eed3caf fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:25:25 +00:00
ops 8e7b69c795 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:29 +00:00
ops 7247c1193a ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing ddaa3a6f42 feat(oapi): 新增移动端渠道绑定开放接口
build / build-push (push) Successful in 59s
- GET /oapi/channel/user 按 (channel, openid) 查已绑定用户,未绑定返回 null
- POST /oapi/channel/bind 凭证绑定:账号密码本服务校验 + 三层查重(openid/账号/unionid)
- POST /oapi/channel/bindByPhone 手机号证明绑定(归属由渠道验签器证明)
- POST /oapi/channel/unbind 按登录账号 + 渠道删绑定行
- 抢绑防护:openid/unionId 仅由服务端从一次性 loginTicket 获取,前端不接触
- 新增 sys_user_channel_rel 实体/仓储/服务,写操作经 @OapiLog 记审计
- 补 ChannelBindServiceTest
2026-10-21 08:52:52 +08:00
chenxing cb40524b8e feat(oapi): user/modules 新增可选 appCode,支持查询指定应用的模块树
- 传 appCode => 按 application_info.app_code 解析目标应用(供托管型应用代渲染导航)
- 未传 => 保持原行为,按验签 appKey 解析调用方应用
- 应用解析失败一律返回空列表(安全优先,禁止意外泄露其他应用模块)
- 参数用 appCode 而非整数 appId:跨环境稳定,与 form-center 的 /app/{appCode}/ 同键
2026-10-21 08:52:42 +08:00
chenxing 195936236d feat(module): 子模块支持按钮权限型,custom_id 放开为可空
- AppModule.customId 去掉 @NotBlank / nullable=false;AppModuleRequest 同步放开必填校验
- AppModuleService 空白归一为 null,仅在有值时做应用内唯一校验;path 同样归一 + trim
- 必须存 NULL 而非 '':'' 在 uk_app_module_custom(app_id, custom_id) 下每应用只能有一条
- 新增幂等 DDL sql/app_module_custom_id_nullable.sql(ddl-auto=update 不会改列属性)
- 补 AppModuleServiceButtonModuleTest
2026-10-21 08:52:34 +08:00
chenxing ad434dbc36 refactor(path): API 前缀收敛为 /api,退役 /{platform} 通配
- Config.API_PATH 由 "/{platform}" 改为 "/api",另留 MOBI_API_PATH 常量做对齐
- ManageAccessInterceptor 白名单改显式 /api 前缀,路径从通配变闭集
- DWebMvcConfig 拦截器注册由 /{platform}/** 改 /api/**,堵掉「任意前缀绕过白名单」的洞
2026-10-21 08:52:26 +08:00
chenxing 035df02f63 部门线P0最小批:sys_group加status启停(单列UPDATE+快照驱逐+oapi透出+160幂等SQL)
build / build-push (push) Successful in 57s
2026-10-20 22:37:22 +08:00
chenxing 2b754c08ca feat(app): 应用矢量图标 icon_key/icon_color 落库 + icon-map 出参 + svg 上传消毒
build / build-push (push) Successful in 1m25s
2026-10-20 16:24:51 +08:00
chenxing 336b50675c ci: apk 源不可达时按可达性自动切换镜像(修复流水线卡死在第一步)
build / build-push (push) Successful in 1m16s
现象:10-20 12:18 / 12:19 两轮构建都失败在第一步 apk add:
  fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main: Permission denied(每个源卡满 60s)
  ERROR: unable to select packages: curl (no such package)
后续 clone / 构建 / 打包 / 建档全部被 skip,代码根本没被编译到。
同一时刻本机访问该源是 200,说明是 runner 容器侧网络受限。

改法:装包前用 busybox wget 依次探测 dl-cdn / 阿里云 / 腾讯云 / 华为云,
取第一个可达源替换 /etc/apk/repositories 后再 apk add;四源全不可达则显式报错退出,
不再以 120 秒超时的形式掩盖真实原因。
2026-10-20 14:18:49 +08:00
chenxing fe6c07cc4b fix(授权): 父级勾选态改「任一子节点选中即选中」+ 无模块应用整应用授权(-appId 哨兵)
build / build-push (push) Failing after 2m2s
1. 父级选中态(组授权 / 角色授权口径统一)
   两页 isAppChecked 由「全部子模块都勾选才 true」改为「任一子孙被勾选即 true」,
   配合前端样式把 zTree checkbox_true_part 图标指向 checkbox_true_full,
   折叠态也能一眼看出「部分授权」,不再依赖视觉很弱的半选。

2. 无子模块应用(如「一周安排」)此前勾选不落库、刷新即失效
   - 新增 constant/AppLevelModuleId:用 -appId 作「整应用授权」的 module_id 哨兵。
     两套授权表都是 (owner_id, module_id) 唯一键,用 0 会让同组/同角色的第二个
     无模块应用撞唯一键;真实模块 id 恒正,负数段天然空闲且权限判定永不命中。
   - GroupAuthzService#resolveModuleIds:应用无任何模块时返回 -appId,使「整应用」
     授权能落库;findGroupAppsWithModules 按 -appId 回填应用级 checked / inheritFlag。
   - AppRoleService:findRoleModuleTree 同口径回填 checked;saveRoleModules 对负数项
     跳过模块校验,改为校验应用存在性与角色授权范围(角色授权是全量覆盖,哨兵必须原样落库)。
   - GroupAuthzService#buildModuleTree:脏数据兜底——parent_id 指向已删模块导致根节点
     全丢时,把全部模块提升为根,避免「有模块的应用」被误判成无模块应用。

涉及前端配套改动见 UserBaseFront 同名提交(GroupAuth.vue / AppModuleCheckTree.vue)。
2026-10-20 12:18:22 +08:00
chenxing c790187f35 学校应用开通硬闸门:单点前置闸门+应用列表/authorized-codes 收窄(D1 保超管 bypass、D2 收敛)
build / build-push (push) Successful in 54s
- GroupAuthzService: findUserModulesForApp 超管短路后插生效学校单点开通前置闸门;新增 findUserAuthorizedAppIds(userId,schoolId) 收窄重载
- AppCategoryService.listBoaAppType 改用收窄口径;authorized-codes 端点迁至 (userId,null) 走当前身份校兜底
- 测试: 新增 GroupAuthzServiceSchoolGateTest,适配 ModuleTree/Alias/BaseFilter/AuthorizedCodes mock 签名
2026-10-12 14:23:05 +08:00
chenxing d7d239e77c 外观SDK化C:UserBase 接入外观代理 starter(端点 GET /api/appearance 三态兜底)
build / build-push (push) Successful in 1m41s
- pom 加 yh-openapi-spring-boot-starter(聚合包例外须显式引出站引擎)+
  yh-appearance-spring-boot-starter(平台新件,proxy 默认直连 UserHome /oapi/appearance;
  不配 yh.open.services.userhome 时端点 503=预期兜底,前端通道 init 静默保键)
- ManageAccessInterceptor 白名单放行 /api/appearance(仅登录可读自身偏好;
  择迹暂存只含本行,工作区既有他人拦截器重构 WIP 未混入)
- 前端零改动(通道 init 裸 fetch;alova 不生成定义)
- 方案:《外观代理SDK化与侧栏默认翻转-实施方案-20261011.md》任务 C
2026-10-11 13:59:53 +08:00
chenxing 34750c03ac feat(oapi): 学校维度应用清单端点 GET /oapi/app/list-by-school(流程中心应用设置源池)
build / build-push (push) Successful in 54s
- 开通口径=学校组单点已开通(sys_group_app_rel where group_id=schId,不沿祖先/不并子树角色,拍板 D7);
  基石应用过滤/端掩码过滤/convertToBoaApp 不透 secret/applyAlias 学校别名合并,骨架复用 listBoaAppType
- 新缓存 schoolAppList('s:'+schId+':c:'+clientType,60s,学校 id 不可枚举→授权/组/应用/别名事件整清)
- AppAuthzChangedEvent/GroupChangedEvent/ApplicationInfoCacheChangedEvent 三事件挂接整清
- 仅含本功能 5 文件;工作区其余未提交改动(icon-map/channel-bind 实验线)未纳入
2026-10-10 21:50:50 +08:00
chenxing 96855a8565 fix(identity): 可选身份列表排序改为「主校置顶→其他校 schId 升序→校内 roleId 升序」
build / build-push (push) Successful in 56s
修复同校多身份被主身份提升拆到头尾(如云海小学教师置顶、管理员落尾)。新增 IdentityService.sortForDisplay 统一 /oapi/user/roles 与 /oapi/identity/available 顺序;主身份行不再单独置顶,主校内部一律按 roleId(老师1→校领导2→管理员7),其余学校按 schId 升序。
2026-10-09 17:36:26 +08:00
chenxing 705cbde7cf fix(app): 应用列表两处——「全部」视图按 id 正序 + 基石应用不进用户端列表
build / build-push (push) Successful in 1m8s
1) 排序(管理端 GET /AppList):未选应用类型(前端「全部」)统一按 id 正序,最新创建的在最后。
   - SUPER:去掉 @SortDefault(sort="sortNo"),改 categoryId==null ? Sort.by(ASC,"id") : Sort.unsorted();
     类别视图排序改由 Specification 内嵌 orderBy(NULL 沉底 + sortNo + id)——NULL 沉底须 case
     表达式,Sort 表达不了,故该分支必须传 unsorted,否则 Pageable 排序会整体覆盖 spec 的 orderBy。
   - SCHOOL:listSchoolApps 按视图分叉,全部视图走新增 appIdOrder(),选中类型仍走生效排序
     (override sortNo 优先、空回落主表、并列 id 升序),学校拖拽排序管理不回归。
   - 口径:sort_no 是「类型内」序号,跨类型打平无全局意义(服务内注释 + 2026-07-22 记忆)。
2) 基石过滤(oapi GET /oapi/app/list):AppCategoryService.listBoaAppType 补 AppNature.isBaseApp
   过滤,UserBase 自身(appKey<=100000)与 app_nature=BASE(UserHome / 移动门户)不进用户端应用列表。
   根因:findUserAuthorizedAppIds 超管分支返回全量,而该读链全程无基石判定——即
   《消息卡片直批与跳转基址统一方案-20260928》§4.2「一开四关」漏掉的第 6 个触点(现收口为「关」)。
   未动 findUserAuthorizedAppIds(被 /oapi/app/authorized-codes 跳转判权共用,改了会连带把
   「跳到基石应用」判无权限);未动 manage 端口径(仅 isUserbaseSelf,保留基石供重置密钥)。

测试:AppCategoryServiceBaseAppFilterTest(3 用例)+ SchoolAppOverrideServiceListSchoolAppsTest(5 用例)
2026-10-09 12:03:49 +08:00
chenxing 90c08a5f14 feat(oapi): 本人联系方式修改端点 POST /oapi/user/self/contact——只写 sys_user phone/email 两字段(空白=不修改),手机号格式/唯一查重+邮箱格式校验,UserChangedEvent 驱逐 boaUser 缓存(含旧 phone: key 提示);附带修复 BoaUserLoader 过时置空:email 列早已存在(实体/管理端/导入均读写),回读 BoaUser 须携带 email(任务B·UserBase 侧)
build / build-push (push) Successful in 2m8s
2026-10-02 08:53:30 +08:00
chenxing f0eaebd186 feat(sysuser): ByIds 批量姓名回显端点——管理面聚合页(消息追溯台收件明细)用,仅超管、窄投影 id/account/realName
build / build-push (push) Failing after 30s
2026-09-29 12:33:43 +08:00
chenxing 8f8a6b8cca feat(app): app_code/app_nature 编辑必填(C5,2026-09-28 拍板)——校验组 Create 扩 {Create,Update},存量 null 历史行不追溯;学校覆盖分支由前端回传原值满足校验(editAppForSchool 只读 name/desc/pcIcon 不受影响)
build / build-push (push) Failing after 49s
2026-09-28 17:07:44 +08:00
chenxing 7e222f19b8 feat(app): 新增 /oapi/app/authorized-codes 判权端点(消息跳转收口 C1)——findUserAuthorizedAppIds 判权 + listAppCodesByIds 映射 app_code(空值跳过/首现去重);数据面与控制器用例补齐 2026-09-28 16:52:37 +08:00
chenxing fe19954d8b fix(test): userAppList 驱逐计数 16→18——上游 SNAPSHOT ClientEnum 增 MINIAPP(9 维度×2 格式),非本线改动 2026-09-28 12:44:28 +08:00
chenxing 257ba2dd64 feat(app): 应用身份统一 app_code/app_nature——五处基石判定收口 + by-code/by-key 开放端点(消息卡片方案 B1) 2026-09-28 12:44:26 +08:00
chenxing 3846ec21a9 chore(config): identity switch-allowed-app-keys 加 100009(流程中心 switch 授权) 2026-09-28 11:54:30 +08:00
chenxing 0b4a596bce feat(app): 注册表多端地址补 miniprogram_url 三层映射(实体/Request/Vo + Boa 出参),160 表列待 DDL 2026-09-28 11:54:29 +08:00
112 changed files with 8100 additions and 97 deletions
+28 -1
View File
@@ -25,7 +25,22 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl(alpine) - name: 安装 git / curl(alpine)
run: apk add --no-cache git curl run: |
set -e
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
chosen=""
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
chosen="${host}"
break
fi
echo "apk 源不可达: ${host}"
done
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
echo "使用 apk 源: ${chosen}"
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -56,6 +71,18 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存) - name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: | run: |
set -e set -e
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f mavenbuild-${MB} 2>/dev/null || true docker rm -f mavenbuild-${MB} 2>/dev/null || true
docker run -d --name mavenbuild-${MB} \ docker run -d --name mavenbuild-${MB} \
--network infra-infra-net \ --network infra-infra-net \
+12
View File
@@ -195,6 +195,18 @@
<groupId>com.yunhai.platform</groupId> <groupId>com.yunhai.platform</groupId>
<artifactId>yh-env-config-spring-boot-starter</artifactId> <artifactId>yh-env-config-spring-boot-starter</artifactId>
</dependency> </dependency>
<!-- 服务间出站调用引擎(YschOpenCall 自动装配):外观代理端点的上游调用链。
UserBase 是聚合 starter 例外,须显式引入(Notice/svc-hall/flow-center 走聚合包已含)。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-openapi-spring-boot-starter</artifactId>
</dependency>
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 代理 UserHome)。
平台件,不进聚合包;前端零消费(通道 init 裸 fetch),alova 勿生成定义。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与 <!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 --> SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
@@ -0,0 +1,38 @@
-- =====================================================================
-- 部门管理 ·「包含子组」落库(方案:部门管理-包含子组落库方案.md)
-- sys_group 增列 include_children:学校节点上的一个开关,
-- 1 = 部门列表连同下级部门一起列出(默认)/ 0 = 只列一级部门
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体已同名映射,执行后重启后端即可生效
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 增列 include_children
-- 粒度:1 学校 : 1 值,与 status 同为节点自身属性,故直接挂在 sys_group,不另建表
-- 默认 1:与升级前的页面表现一致,存量学校无需回填
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动加列,但**不带** NOT NULL/DEFAULT/COMMENT,
-- 存量会留 NULL。因此本脚本建议先执行,保证列定义可控;即使漏执行,前端已按 null = 含子组 兜底
-- ---------------------------------------------------------------------------
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_group'
AND COLUMN_NAME = 'include_children');
SET @sql := IF(@col = 0,
'ALTER TABLE sys_group ADD COLUMN include_children TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''包含子组:1=部门列表含下级部门,0=仅本级'' AFTER status',
'SELECT ''include_children already exists'' AS msg');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 2. 核验(可选)
-- ---------------------------------------------------------------------------
-- SHOW COLUMNS FROM sys_group LIKE 'include_children';
-- SELECT COUNT(*) AS total, SUM(include_children = 1) AS include_on FROM sys_group;
-- ---------------------------------------------------------------------------
-- 回滚(仅当确认不再需要该能力时执行;只加列不改列,代码回滚无需删列)
-- ---------------------------------------------------------------------------
-- ALTER TABLE sys_group DROP COLUMN include_children;
@@ -0,0 +1,102 @@
-- =====================================================================
-- UserBase 双来源部门 · CUSTOM 侧建表(任务书 USERBASE_DEPARTMENT_QODER_TASK.md T1 / 规格书 UB-1)
-- 三张新表:ub_custom_dept_category(自定义分类)/ ub_custom_department(自定义部门)
-- / ub_custom_department_member(部门成员关系)
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体与列同名映射,执行后重启后端即可生效
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动建表,但**不带** NOT NULL/DEFAULT/COMMENT/唯一键,
-- 且 del_marker 之类参与唯一约束的列必须由本脚本定义,故务必先执行本脚本
-- 口径:school_id = sys_group 中 type=2(学校组)节点的 id,与 AccessScopeService.scopeGroupIds() 同域
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 自定义分类(学校的容器层,本身无启停、无成员)
-- 名称唯一:del_marker 技巧——MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住「软删后再激活同名」,
-- 故软删时把 del_marker 置为该行 id,未删行恒为 0,同校同名只能有一条**有效**行;
-- name 列永不改写(软删保留原名,保证历史解析拿到原名、旧引用不指向新对象)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_dept_category (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(不可变稳定 ID)',
school_id INT NOT NULL COMMENT '学校组 id(sys_group.type=2 的节点 id)',
name VARCHAR(64) NOT NULL COMMENT '分类名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(不是业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键,保证同名只挡有效行',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_name_marker (school_id, name, del_marker),
KEY idx_school_sort (school_id, is_deleted, sort_no)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门分类(学校级容器,非可选部门)';
-- ---------------------------------------------------------------------------
-- 2. 自定义部门(可配成员、可启停的实际可选对象)
-- 唯一键=同校同分类下未软删的部门名不重复;跨分类、SYSTEM 与 CUSTOM 之间允许重名(A06)
-- enabled 语义直读 1=启用/0=停用,**与 sys_group.status(0=启用/1=停用)方向相反,严禁混用**
-- 软删不级联删成员:成员关系保留在库,由查询侧按部门 is_deleted 过滤(A12)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(CUSTOM 侧独立 ID,与 SYSTEM 组 id 不同域,引用须带 departmentType)',
school_id INT NOT NULL COMMENT '学校组 id(冗余自分类,写口服务端复核)',
category_id INT NOT NULL COMMENT '所属分类 ub_custom_dept_category.id,必须同校',
name VARCHAR(64) NOT NULL COMMENT '部门名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同分类内展示顺序,越小越靠前',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启停:1=启用 / 0=停用(新建默认启用)',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(删除不是第三种业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_category_name_marker (school_id, category_id, name, del_marker),
KEY idx_category_list (category_id, is_deleted, sort_no),
KEY idx_school_list (school_id, is_deleted, enabled)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学校自定义部门(与 SYSTEM 组织树完全分离)';
-- ---------------------------------------------------------------------------
-- 3. 自定义部门成员关系(引用 sys_user.id,不复制账号)
-- 移除成员=物理删该关系行(本仓无软删惯例,增删审计靠 @OperationLog),不得残留有效成员
-- uk 挡不住并发重复插入之外的场景(如软删部门后重新激活)由服务端逐个复核兜底
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department_member (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
school_id INT NOT NULL COMMENT '学校组 id(与部门同校,写口服务端复核)',
department_id INT NOT NULL COMMENT 'ub_custom_department.id',
user_id INT NOT NULL COMMENT 'sys_user.id(本校教职工,排除学生组成员)',
create_user INT NOT NULL COMMENT '添加人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '添加时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_department_user (department_id, user_id),
KEY idx_department (department_id),
KEY idx_school (school_id),
KEY idx_user (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门成员关系(一个用户可属同校多个部门)';
-- ---------------------------------------------------------------------------
-- 4. 核验(可选,执行后逐条确认)
-- ---------------------------------------------------------------------------
-- SHOW CREATE TABLE ub_custom_dept_category;
-- SHOW CREATE TABLE ub_custom_department;
-- SHOW CREATE TABLE ub_custom_department_member;
-- SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'ub_custom%';
-- 唯一键生效自检(手工插入应报 1062):
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW());
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW()); -- 期望失败
-- DELETE FROM ub_custom_dept_category WHERE school_id = 1 AND name = '自检分类';
-- ---------------------------------------------------------------------------
-- 回滚(本期未上线,回滚即删表;CUSTOM 数据不与 sys_group 有任何写耦合)
-- ---------------------------------------------------------------------------
-- DROP TABLE IF EXISTS ub_custom_department_member;
-- DROP TABLE IF EXISTS ub_custom_department;
-- DROP TABLE IF EXISTS ub_custom_dept_category;
+29
View File
@@ -0,0 +1,29 @@
-- sys_group 加启停状态列(2026-10-20 部门线 P0 最小批)
-- 目标环境:160(MySQL 8,容器网 mysql8:3306,库见 UserBase 生产连接串)
-- 幂等:INFORMATION_SCHEMA 判列存在即跳过,可重复执行;160 一律以本脚本为准(不依赖 ddl-auto)
-- 回滚:ALTER TABLE sys_group DROP COLUMN status;(存量数据全 0=启用,回滚无行为影响)
DELIMITER $$
DROP PROCEDURE IF EXISTS add_sys_group_status $$
CREATE PROCEDURE add_sys_group_status()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_group'
AND COLUMN_NAME = 'status'
) THEN
ALTER TABLE sys_group
ADD COLUMN status TINYINT NOT NULL DEFAULT 0
COMMENT '0 启用 / 1 停用(停用后新建选不到,已有引用与在途流程不受影响)';
END IF;
END $$
CALL add_sys_group_status() $$
DROP PROCEDURE IF EXISTS add_sys_group_status $$
DELIMITER ;
-- 执行后核对(计数入台账,业务数据不入):
-- SELECT status, COUNT(*) FROM sys_group GROUP BY status; -- 预期全部 status=0
@@ -19,7 +19,7 @@ import java.util.List;
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问); * 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置, * env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br> * 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br> * 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。 * SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
* *
* @author chenx * @author chenx
@@ -29,16 +29,18 @@ import java.util.List;
public class ManageAccessInterceptor implements HandlerInterceptor { public class ManageAccessInterceptor implements HandlerInterceptor {
/** /**
* 白名单(Ant 风格,* 匹配 platform 前缀): * 白名单(显式 /api 前缀,路径闭集):
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理; * 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。 * env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
*/ */
private static final List<String> WHITELIST = Arrays.asList( private static final List<String> WHITELIST = Arrays.asList(
"/*/base/GetUserInfo", "/api/base/GetUserInfo",
"/*/base/GetUserScope", "/api/base/GetUserScope",
"/*/base/GetSysDict", "/api/base/GetSysDict",
"/*/base/RedirectTo/*", "/api/base/RedirectTo/*",
"/*/env-config"); "/api/appearance",
"/api/env-config");
private static final AntPathMatcher MATCHER = new AntPathMatcher(); private static final AntPathMatcher MATCHER = new AntPathMatcher();
@@ -119,6 +119,20 @@ public final class CacheNames {
*/ */
public static final String USER_APP_LIST = "userAppList"; public static final String USER_APP_LIST = "userAppList";
/**
* 学校维度应用清单(GET /oapi/app/list-by-school 读路径,2026-10-10;流程中心 flw_app 源池)。<br>
* key 规则:{@code 's:' + schoolId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())}——
* 学校 id 不可枚举,用户级变更不涉及(学校层无用户),授权/组/应用/别名事件一律整清,
* 其余直改 DB 场景靠 TTL 自愈(同 USER_APP_LIST 显式学校后缀策略);<br>
* TTL 60s(同 USER_APP_LIST 档位:脏了=流程中心显示过期应用池,短档足够);<br>
* 存放:Redis;依赖表:sys_group_app_rel、app_category、application_info、
* sys_school_app_override、sys_school_category_override;<br>
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(授权域写口整清,含学校别名)+
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
* (组开通面变化 / 应用启停排序删除整清)。
*/
public static final String SCHOOL_APP_LIST = "schoolAppList";
/** /**
* 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br> * 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br>
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups * key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
@@ -234,6 +248,12 @@ public final class CacheNames {
"'u:'+userId(整张 groupId→status Map)", "'u:'+userId(整张 groupId→status Map)",
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener", "UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
"sys_user_group_rel")); "sys_user_group_rel"));
m.put(SCHOOL_APP_LIST, new Meta(SCHOOL_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
"'s:'+schoolId+':c:'+(clientType?.name()?:'all')(8 client × 学校 id,不可枚举→整清)",
"AppAuthzChangedEvent(整清,含学校别名写口)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent"
+ "(整清)→ CacheInvalidationListener;直改 DB 靠 TTL 自愈",
"sys_group_app_rel, app_category, application_info, sys_group,"
+ " sys_school_app_override, sys_school_category_override"));
REGISTRY = Collections.unmodifiableMap(m); REGISTRY = Collections.unmodifiableMap(m);
} }
@@ -13,9 +13,10 @@ public enum Config
Inst; Inst;
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成 // 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠, // "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。 // 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi public static final String API_PATH = "/api"; // PC 管理端(CAS 会话),路径闭集:/manage 等旧前缀随之 404
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 token;UserBase 自身不挂 mobiapi 控制器,仅供常量对齐)
public static final String OPEN_API_PATH = "/oapi/"; public static final String OPEN_API_PATH = "/oapi/";
public String getString(String key) public String getString(String key)
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
.addPathPatterns("/**") .addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**"); .excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。 // 管理端统一防线:NONE 级 403(白名单见拦截器内)。
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。 // 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚); // /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
// /v3/api-docs、/swagger-ui:接口文档工具链。 // /v3/api-docs、/swagger-ui:接口文档工具链。
registry.addInterceptor(manageAccessInterceptor) registry.addInterceptor(manageAccessInterceptor)
.addPathPatterns("/{platform}/**") .addPathPatterns("/api/**")
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**", .excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
"/webjars/**"); "/webjars/**");
@@ -0,0 +1,45 @@
package com.yunhai.userbase.constant;
/**
* 「整应用授权」的 module_id 哨兵编码。<br>
* <p>
* 授权关系表 {@code sys_group_module_perm} / {@code sys_app_role_module_rel} 都只有
* (owner_id, module_id) 两列,<b>没有 app_id</b>。当某个应用下没有任何 {@code app_module} 记录时
* (如「一周安排」这类单页应用),模块级授权无处可挂,于是用 <b>-appId</b> 作为该应用
* 「整体已授权」的哨兵值:
* <ul>
* <li><b>不是 workaround</b>:module_id 命名空间里真实模块 id 恒为正整数,负数段天然是空闲的
* 应用级通道,与真实模块不会冲突;</li>
* <li>不同应用编码不同,满足 {@code uk_group_module(group_id, module_id)} /
* {@code uk_app_role_module(role_id, module_id)} 唯一键,同组/同角色可同时授权多个无模块应用;</li>
* <li>权限判定链路(hasModulePermission / collectEnabledRoleModuleIds 等)按真实 moduleId 匹配,
* 哨兵值永远匹配不上,不会被误判成某个模块的权限。</li>
* </ul>
* 写入侧由 Service 内部把「入参 moduleId=0(整应用语义)」翻译成本编码,前端无需感知。
*/
public final class AppLevelModuleId {
private AppLevelModuleId() {
}
/**
* 应用ID → 整应用授权的哨兵 module_id(-appId)。appId 为空时返回 null。
*/
public static Integer of(Integer appId) {
return appId == null ? null : -appId;
}
/**
* 哨兵 module_id → 应用ID;传入真实模块 id(正数)或 null 时返回 null。
*/
public static Integer resolveAppId(Integer moduleId) {
return (moduleId != null && moduleId < 0) ? -moduleId : null;
}
/**
* 是否为整应用授权的哨兵值。
*/
public static boolean isAppLevel(Integer moduleId) {
return moduleId != null && moduleId < 0;
}
}
@@ -0,0 +1,39 @@
package com.yunhai.userbase.constant;
/**
* 应用性质(application_info.app_nature)。<br>
* BASE=基石应用:平台基建身份(UserHome / 移动门户等),需要出站签名身份但不进门户开通/授权/模块管理;<br>
* BUSINESS=业务应用:门户可见,参与开通/授权。<br>
* 与 app_category 的「基础应用」分类无关——那边是"类型",这边是"性质"(两界面不同屏,存储层并存)。
*/
public enum AppNature { BASE, BUSINESS;
/**
* UserBase 自身的 appKey —— appKey 从 100000 起自增,"基石应用段"起点(application_bootstrap.sql)。<br>
* 它是验签的管理者,不拿自己的身份被验签,故不标 app_nature,由 {@link #isUserbaseSelf} 覆盖。
*/
public static final int USER_CENTER_APP_KEY = 100000;
/**
* 条件一:UserBase 自身(延续原「appKey &gt; 100000」排除逻辑的取反语义,行为不变)。
*/
public static boolean isUserbaseSelf(Integer appKey) {
return appKey != null && appKey <= USER_CENTER_APP_KEY;
}
/**
* 条件二:显式标记的基石应用(UserHome / 移动门户等)。
*/
public static boolean isMarkedBase(String appNature) {
return BASE.name().equals(appNature);
}
/**
* 是否基石应用(不参与应用开通 / 授权 / 模块管理等业务功能)。<br>
* 两条件取「或」。注意 JPQL @Query 内无法调用本方法——Repository 三处为常量拼接的 SQL 形态,
* 语义单点仍在本枚举,改动须同步(含三值逻辑 NULL 分支)。
*/
public static boolean isBaseApp(Integer appKey, String appNature) {
return isUserbaseSelf(appKey) || isMarkedBase(appNature);
}
}
@@ -30,7 +30,6 @@ import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils; import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -195,8 +194,14 @@ public class AppSettingsController {
@GetMapping("/AppList") @GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery, public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort,
@RequestParam(value = "schoolId", required = false) Integer schoolId) { @RequestParam(value = "schoolId", required = false) Integer schoolId) {
// 排序必须带唯一兜底键:同键行 MySQL 不保证稳定序,加删一行即整组洗牌(应用列表乱序的根因)。
// 全部(未选类别)= id 升序——sort_no 是「类型内」序号,跨类型打平无全局意义;
// 选中类别 = 未排序(NULL)沉底 + sortNo 升序 + id 兜底——由 Specification 内嵌 orderBy 实现
// (NULL 沉底须 case 表达式,Sort 只走属性解析表达不了;此处传 unsorted,否则 Pageable 排序会覆盖 spec 的 orderBy)
Sort sort = query.getCategoryId() == null
? Sort.by(Sort.Direction.ASC, "id")
: Sort.unsorted();
if (isSchoolLevel()) { if (isSchoolLevel()) {
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校) // 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
AccessScope scope = accessScopeService.resolveCurrentScope(); AccessScope scope = accessScopeService.resolveCurrentScope();
@@ -27,7 +27,7 @@ import java.util.TreeSet;
/** /**
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br> * 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
* *
* {@code GET /{platform}/cache/registry} 输出三块内容:<br> * {@code GET /api/cache/registry} 输出三块内容:<br>
* <ul> * <ul>
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表) * <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li> * × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理(CUSTOM 侧,部门管理页左侧平铺列表的数据源)。<br>
* 动词式端点、{@code /api} 前缀与 {@code SysGroupController} 同范式;
* 四级访问范围与跨校拦截在 Service 内统一执行(写口只认登录身份,不认请求体 schoolId)。<br>
* 分类是容器:无启停、无成员;删除只有软删,且非空分类明确拒绝(无级联)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/deptcategory/")
public class CustomDeptCategoryController {
@Autowired
private ICustomDeptCategoryService customDeptCategoryService;
@Operation(operationId = "customDeptCategoryList")
@GetMapping("List")
public List<CustomDeptCategoryVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId) {
return customDeptCategoryService.list(schoolId);
}
@OperationLog("新增自定义部门分类")
@Operation(operationId = "customDeptCategoryAdd")
@PostMapping("Add")
public CustomDeptCategoryVo add(@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.add(request);
}
@OperationLog("重命名自定义部门分类")
@Operation(operationId = "customDeptCategoryEdit")
@PostMapping("Edit/{id}")
public CustomDeptCategoryVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.rename(id, request);
}
@OperationLog("自定义部门分类排序")
@Operation(operationId = "customDeptCategorySort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDeptCategorySortRequest request) {
customDeptCategoryService.sort(request);
}
/**
* 软删分类:分类下仍有有效部门时明确拒绝(A14),不做任何级联删除。
*/
@OperationLog("删除自定义部门分类")
@Operation(operationId = "customDeptCategoryDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDeptCategoryService.softDelete(id);
}
}
@@ -0,0 +1,149 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.service.ICustomDepartmentService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理(CUSTOM 侧:部门本体 + 启停 + 软删 + 成员)。<br>
* 与 SYSTEM 组织树完全隔离:本控制器不读写 {@code sys_group} 的任何写路径,
* 也不提供删除 SYSTEM 部门的入口(任务书 D1)。<br>
* 跨校/学生成员等校验全部在 Service 服务端执行,前端传值只作核对。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/customdept/")
public class CustomDeptController {
@Autowired
private ICustomDepartmentService customDepartmentService;
@Operation(operationId = "customDeptList")
@GetMapping("List")
public List<CustomDepartmentVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId,
@RequestParam("categoryId") Integer categoryId) {
return customDepartmentService.list(schoolId, categoryId);
}
@OperationLog("新增自定义部门")
@Operation(operationId = "customDeptAdd")
@PostMapping("Add")
public CustomDepartmentVo add(@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.add(request);
}
@OperationLog("重命名自定义部门")
@Operation(operationId = "customDeptEdit")
@PostMapping("Edit/{id}")
public CustomDepartmentVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.rename(id, request);
}
/**
* 移动至本校其他分类:目标分类跨校直接拒绝。
*/
@OperationLog("移动自定义部门")
@Operation(operationId = "customDeptMove")
@PostMapping("Move/{id}")
public CustomDepartmentVo move(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMoveRequest request) {
return customDepartmentService.move(id, request);
}
@OperationLog("自定义部门排序")
@Operation(operationId = "customDeptSort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDepartmentSortRequest request) {
customDepartmentService.sort(request);
}
/**
* 停用自定义部门:单列 UPDATE enabled=0。<br>
* 停用只影响「选」(新业务选不到),不影响「看」与「办」(历史/在途仍可解析),与 SYSTEM 同口径。
*/
@OperationLog("停用自定义部门")
@Operation(operationId = "customDeptDisable")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, false);
}
/**
* 启用自定义部门:单列 UPDATE enabled=1,原 id 与成员保持不变(A11)。
*/
@OperationLog("启用自定义部门")
@Operation(operationId = "customDeptEnable")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, true);
}
/**
* 软删自定义部门:默认列表不再出现,历史引用仍可解析(A12);成员关系保留在库、由查询侧过滤。
*/
@OperationLog("删除自定义部门")
@Operation(operationId = "customDeptDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDepartmentService.softDelete(id);
}
@Operation(operationId = "customDeptMembers")
@GetMapping("Members/{id}")
public List<CustomDepartmentMemberVo> members(@PathVariable Integer id) {
return customDepartmentService.members(id);
}
/**
* 可添加的候选教职工:本校教职工(服务端排除学生组)且尚未在该部门。<br>
* 现有 {@code GroupAuthz.GroupUsers} 是组维度、会带出学生组用户,不能当候选源,故单独成口(D4)。
*/
@Operation(operationId = "customDeptCandidateUsers")
@GetMapping("CandidateUsers/{id}")
public List<CustomDepartmentMemberVo> candidateUsers(@PathVariable Integer id,
@RequestParam(value = "keyword", required = false) String keyword) {
return customDepartmentService.candidateUsers(id, keyword);
}
/**
* 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,重复添加按幂等跳过(A07/A08)。
*/
@OperationLog("添加自定义部门成员")
@Operation(operationId = "customDeptAddMembers")
@PostMapping("AddMembers/{id}")
public void addMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.addMembers(id, request);
}
/**
* 移除成员:物理删关系行,不残留有效成员。
*/
@OperationLog("移除自定义部门成员")
@Operation(operationId = "customDeptRemoveMembers")
@PostMapping("RemoveMembers/{id}")
public void removeMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.removeMembers(id, request);
}
}
@@ -4,6 +4,7 @@ import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config; import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest; import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupIncludeChildrenRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest; import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupQuery; import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest; import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
@@ -65,6 +66,46 @@ public class SysGroupController {
sysGroupService.delete(id); sysGroupService.delete(id);
} }
/**
* 停用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=1,不碰其他任何表与列。<br>
* 不判组类型(status 当前无消费方);删除行为不动。范式对齐 SysUserController.Disable。
*/
@OperationLog("停用组")
@Operation(operationId = "sysGroupDisable")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
accessScopeService.requireGroupInScope(id);
accessScopeService.requireGroupModifiable(id);
sysGroupService.changeStatus(id, true);
}
/**
* 启用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=0。
*/
@OperationLog("启用组")
@Operation(operationId = "sysGroupEnable")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
accessScopeService.requireGroupInScope(id);
accessScopeService.requireGroupModifiable(id);
sysGroupService.changeStatus(id, false);
}
/**
* 设置「包含子组」(2026-10-21 部门线):该学校在部门管理页是否连同下级部门一起列出。<br>
* 单列 UPDATE sys_group.include_children,不碰其他列与表。<br>
* 权限<b>只用</b> {@code requireGroupInScope}:不能加 {@code requireGroupModifiable}——
* 后者禁止 SCHOOL 级操作学校组本身,而本属性恰恰是学校管理员必须能改的本校属性。
*/
@OperationLog("设置包含子组")
@Operation(operationId = "sysGroupSetIncludeChildren")
@PostMapping("SetIncludeChildren/{id}")
public void setIncludeChildren(@PathVariable Integer id,
@RequestBody @Validated SysGroupIncludeChildrenRequest request) {
accessScopeService.requireGroupInScope(id);
sysGroupService.changeIncludeChildren(id, Boolean.TRUE.equals(request.getIncludeChildren()));
}
@Operation(operationId = "sysGroupList") @Operation(operationId = "sysGroupList")
@GetMapping("/List") @GetMapping("/List")
public List<SysGroupVo> list(SysGroupQuery query) { public List<SysGroupVo> list(SysGroupQuery query) {
@@ -28,7 +28,10 @@ import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** /**
* @author chenx * @author chenx
@@ -203,4 +206,22 @@ public class SysUserController {
requireQueryGroupInScope(groupId); requireQueryGroupInScope(groupId);
sysUserService.exportUsers(groupId, withSub, name, response); sysUserService.exportUsers(groupId, withSub, name, response);
} }
/**
* 按 id 批量取用户展示信息(id/账号/姓名),管理面聚合页(消息/待办追溯台收件明细)回显名称用。<br>
* 只读窄投影:不透出手机号/身份证等敏感列;用户目录属平台主数据,仅超管。
*/
@GetMapping("ByIds")
public List<Map<String, Object>> byIds(@RequestParam("ids") List<Integer> ids) {
accessScopeService.requireSuper();
return sysUserService.listByIds(ids).stream()
.map(u -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", u.getId());
item.put("account", u.getAccount());
item.put("realName", u.getRealName());
return item;
})
.collect(Collectors.toList());
}
} }
@@ -5,9 +5,13 @@ import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.config.Config; import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.AppActivatedVo;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo; import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.service.IAppCategoryService; import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
@@ -33,6 +37,12 @@ public class AppController {
@Autowired @Autowired
private IApplicationInfoService applicationInfoService; private IApplicationInfoService applicationInfoService;
@Autowired
private IGroupAuthzService groupAuthzService;
@Autowired
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
/** /**
* 按主键 ID 查询应用信息(带缓存)。<br> * 按主键 ID 查询应用信息(带缓存)。<br>
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br> * 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
@@ -43,7 +53,53 @@ public class AppController {
*/ */
@GetMapping("{appId}") @GetMapping("{appId}")
public ApplicationInfoVo byId(@PathVariable Integer appId) { public ApplicationInfoVo byId(@PathVariable Integer appId) {
ApplicationInfo info = applicationInfoService.getById(appId); return toVo(applicationInfoService.getById(appId));
}
/**
* 按技术标识查询应用信息(跳转查询键主用端点)。<br>
* app_code = projman app.name(如 notice/flow-center),即消息 {@code jump_app} 的值域;<br>
* 查不到返回 null(与 byId 契约一致)。返回视图清空 appSecret。
*
* @param appCode 技术标识(必填)
* @return 应用信息视图;不存在时返回 null
*/
@GetMapping("by-code/{appCode}")
public ApplicationInfoVo byCode(@PathVariable String appCode) {
return toVo(applicationInfoService.getByAppCode(appCode));
}
/**
* 按整型 appKey 查询应用信息(兼容兜底端点,存量自发场景按数字回落)。<br>
* ⚠️ 查的是 app_key 列而非主键 id——实测 id ≠ app_key(15 行中仅 2 行相等,
* 如流程中心 appKey=100009 的主键是 100011),按 id 查会把「流程中心」查成「课堂作业」。<br>
* 查不到返回 null;返回视图清空 appSecret。
*
* @param appKey 应用标识(必填)
* @return 应用信息视图;不存在时返回 null
*/
@GetMapping("by-key/{appKey}")
public ApplicationInfoVo byKey(@PathVariable Integer appKey) {
return toVo(applicationInfoService.getByAppKey(appKey));
}
/**
* 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。<br>
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}(2026-10-12 硬闸门 D2 收敛:
* 走生效学校锚点单点开通集合,不沿祖先、角色不补充未开通应用,超级管理员返回全部),
* 再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
* 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
*
* @param userId 用户ID(必填)
* @return 有权应用的 app_code 集合(可能为空,不为 null)
*/
@GetMapping("authorized-codes")
public List<String> authorizedCodes(@RequestParam("userId") Integer userId) {
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId, null));
}
private ApplicationInfoVo toVo(ApplicationInfo info) {
if (info == null) { if (info == null) {
return null; return null;
} }
@@ -56,8 +112,9 @@ public class AppController {
/** /**
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br> * 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用), * 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)}
* 超级管理员返回全部;不透出 appSecret。<br> * ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部;
* 不透出 appSecret。<br>
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br> * clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5); * schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。 * 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
@@ -75,4 +132,60 @@ public class AppController {
: IEnum.getByKey(ClientEnum.class, clientType); : IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppType(userId, clientEnum, schoolId); return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
} }
/**
* 按学校维度查询该校已开通的应用列表(按应用类型分组,已合并学校别名,2026-10-10)。<br>
* 开通口径 = 学校组<b>单点</b>已开通(sys_group_app_rel where group_id=schoolId;开通不继承,
* 不并入子组/角色模块应用)——学校把应用只开给子组时本端点看不到,属口径自然结果。<br>
* 基石应用(UserBase 自身 / BASE)不返回;不透出 appSecret。<br>
* schoolId 必填且须为合法学校组锚点,否则 code=5。<br>
* 消费方(flow-center 应用设置)可传 clientType=null 不过滤端,避免漏纯移动端应用。
*
* @param schoolId 学校组ID(必填)
* @param clientType 客户端类型 key(可选;null=不按端过滤)
* @return 应用类型分组列表;未开通应用的类型 apps 为空数组
*/
@GetMapping("list-by-school")
public List<BoaAppType> listBySchool(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "clientType", required = false) Integer clientType) {
ClientEnum clientEnum = (clientType == null) ? null
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
}
/**
* 全量应用矢量图标映射(AppIcon 资产库通道):[{id, iconKey, iconColor}],仅含已配置行。<br>
* 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段;
* UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。<br>
* 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。
*/
@GetMapping("icon-map")
public List<AppIconMapVo> iconMap() {
return applicationInfoService.listIconMap();
}
/**
* 应用×学校单点开通判定(2026-10-25 T01 最小只读口)。<br>
* 权威事实源 = {@code sys_group_app_rel}(组应用开通表,学校组单点、不沿祖先、不并角色模块),
* 与 {@code GET /oapi/app/list-by-school} 同一口径,只是收敛成 boolean 判定——
* form-center/flow-center 等下游把本端点作为「该应用是否在该校开通」的平台资格第二重证明
* (fail-closed:本端点不可达时下游必须拒绝,不得降级为"仅验签通过")。<br>
* 应用不存在(含已注销)返回 activated=false,不区分"没开通"与"没这应用",
* 避免把应用存在性做成探测面;schoolId 非法(非学校组锚点)按既有 {@code assertSchoolId}
* 口径抛业务错误(code=5)。只读、无敏感字段。
*
* @param appId 应用主键 ID(必填;稳定主体,非 appKey 凭据索引)
* @param schoolId 学校组 ID(必填)
*/
@GetMapping("activated")
public AppActivatedVo activated(@RequestParam("appId") Integer appId,
@RequestParam("schoolId") Integer schoolId) {
if (appId == null || schoolId == null) {
throw new DException("appId 与 schoolId 必填");
}
sysGroupService.assertSchoolId(schoolId);
boolean appExists = applicationInfoService.getById(appId) != null;
boolean activated = appExists && groupAuthzService.findAuthorizedAppIds(schoolId).contains(appId);
return new AppActivatedVo(appId, schoolId, activated);
}
} }
@@ -0,0 +1,71 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.channel.ChannelBindByPhoneRequest;
import com.yunhai.userbase.model.channel.ChannelBindRequest;
import com.yunhai.userbase.model.channel.ChannelUnbindRequest;
import com.yunhai.userbase.service.impl.ChannelBindService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 移动端渠道绑定开放接口(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。<br>
* <br>
* 供 SDK 登录端点(门户 / 各子应用)经 yh-app-auth 签名调用({@code /oapi/channel/...});
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制。<br>
* 绑定身份以服务端校验为准:openid/unionId 来自一次性 loginTicket,账号密码/手机号在本服务校验,
* 前端永不接触 openid/unionid(抢绑防护,方案 §3.4)。<br>
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
*
* @author chenx
* @date 2026/9/25
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "channel/")
public class ChannelBindController {
@Autowired
private ChannelBindService channelBindService;
/**
* 按 (channel, openid) 查已绑定用户。
*
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null(data 为 null,code=0)
*/
@GetMapping("user")
public BoaUser user(@RequestParam("channel") String channel,
@RequestParam("openid") String openid) {
return channelBindService.findBoundUser(channel, openid);
}
/** 凭证绑定:账号密码校验 + 三层查重(openid/账号/unionid)通过后写绑定关系 */
@OapiLog
@PostMapping("bind")
public BoaUser bind(@RequestBody @Validated ChannelBindRequest request) {
return channelBindService.bindByCredential(request.getChannel(), request.getOpenid(),
request.getUnionId(), request.getAccount(), request.getPassword());
}
/** 手机号证明绑定(小程序一键手机号):手机号归属由渠道验签器证明 */
@OapiLog
@PostMapping("bindByPhone")
public BoaUser bindByPhone(@RequestBody @Validated ChannelBindByPhoneRequest request) {
return channelBindService.bindByPhone(request.getChannel(), request.getOpenid(),
request.getUnionId(), request.getPhone());
}
/** 解绑:按登录账号 + 渠道删绑定行(account 来自 token payload,服务端定位) */
@OapiLog
@PostMapping("unbind")
public void unbind(@RequestBody @Validated ChannelUnbindRequest request) {
channelBindService.unbind(request.getChannel(), request.getAccount());
}
}
@@ -0,0 +1,166 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 通用部门目录开放接口({@code /oapi/departments/...},规格书 UB-2)。<br>
* <br>
* 鉴权链路照 {@code /oapi/group} 现行做法:验签由 {@code OpenApiAuthEnforceConfiguration} 的
* {@code RequireAppSignFilter} 在过滤器层强制(无签名直接 401),本层不做验签;
* 但<b>验签只证明「是哪个应用」</b>,能否读这所学校由 {@code sys_group_app_rel}(应用在该校已开通)决定,
* 越校读取抛业务异常(A15)。<br>
* 引用一律 {@code schoolId + departmentType + departmentId} 三件套,禁止以名称做键。
*/
@Slf4j
@RestController
@RequestMapping(Config.OPEN_API_PATH + "departments")
public class DepartmentController {
/** 成员解析上下文:供新的指派/绑定(严档,也是缺省)。 */
private static final String SCOPE_SELECT = "select";
/** 成员解析上下文:已有实例的历史解析(宽档,停用/软删照发)。 */
private static final String SCOPE_HISTORY = "history";
@Autowired
private IDepartmentDirectoryService departmentDirectoryService;
@Autowired
private IApplicationInfoService applicationInfoService;
/**
* 可选部门目录:默认只返回启用且未软删的条目。<br>
* {@code type=SYSTEM|CUSTOM}(缺省两类都返回);{@code categoryId} 只对 CUSTOM 生效;
* {@code withChildren=true} 只对 SYSTEM 生效,连同部门下级一起返回(扁平 + parentId)。
*/
@GetMapping
public List<DepartmentEntryVo> list(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "categoryId", required = false) Integer categoryId,
@RequestParam(value = "withChildren", required = false) Boolean withChildren,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, true);
return departmentDirectoryService.list(resolveCallerAppId(request), schoolId, parsed,
categoryId, Boolean.TRUE.equals(withChildren));
}
/**
* CUSTOM 分类目录(学校通用,不按调用方应用隔离;分类只是容器,不能指派人员)。
*/
@GetMapping("categories")
public List<CustomDeptCategoryVo> categories(@RequestParam("schoolId") Integer schoolId,
HttpServletRequest request) {
return departmentDirectoryService.categories(resolveCallerAppId(request), schoolId);
}
/**
* 批量解析引用(<b>唯一</b>允许返回停用/软删部门的读口,A12)。<br>
* 一批里任一学校未对本应用开通 → 整批拒绝;解析不到的引用返回 name=null、selectable=false。
*/
@PostMapping("resolve-batch")
public List<DepartmentEntryVo> resolveBatch(@RequestBody @Validated List<DepartmentRefRequest> refs,
HttpServletRequest request) {
return departmentDirectoryService.resolveBatch(resolveCallerAppId(request), refs);
}
/**
* 部门成员解析。{@code scope=select}(缺省)=供新的指派:部门停用/软删直接拒绝;<br>
* {@code scope=history}=已有实例解析:停用/软删照常返回成员,不影响在途(A17)。<br>
* 出参字段与 {@code /oapi/group/users} 同形状(轻量 BoaUser,禁用账号靠 status=0 标识、不静默剔除)。
*/
@GetMapping("{type}/{id}/members")
public List<BoaUser> members(@PathVariable String type,
@PathVariable Integer id,
@RequestParam(value = "scope", required = false) String scope,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, false);
return departmentDirectoryService.members(resolveCallerAppId(request), parsed, id, parseScope(scope));
}
// ===================== 私有 =====================
/**
* 解析成员上下文:缺省与 {@code select} 都按「供新的指派」处理(更严的一档做默认),
* 只有明确 {@code history} 才放行停用/软删;其余取值直接拒绝——写错的 scope 不能静默变成宽口径。
*/
private boolean parseScope(String scope) {
String normalized = scope == null ? "" : scope.trim().toLowerCase();
if (normalized.isEmpty() || SCOPE_SELECT.equals(normalized)) {
return true;
}
if (SCOPE_HISTORY.equals(normalized)) {
return false;
}
throw new DException("scope 只允许 select / history");
}
/**
* 解析来源类型:路径段必解析(非法即拒),查询参数缺省表示「两类都要」。
*/
private DepartmentTypeEnum parseType(String raw, boolean allowMissing) {
if (raw == null || raw.trim().isEmpty()) {
if (allowMissing) {
return null;
}
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
DepartmentTypeEnum parsed = DepartmentTypeEnum.of(raw);
if (parsed == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
return parsed;
}
/**
* 从验签属性取 appKey,再映射为 application_info 主键(与 {@code oapi/user} 的 {@code modules} 同一链路)。<br>
* 解析不出应用 → 传 null 给 Service,由其按「无法识别调用方」拒绝:部门目录不能匿名兜底成「全量可读」。
*/
private Integer resolveCallerAppId(HttpServletRequest request) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
log.warn("[oapi:departments] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
request.getRequestURI());
return null;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (appKeyStr.trim().isEmpty()) {
log.warn("[oapi:departments] AppAuth attr is blank, uri={}", request.getRequestURI());
return null;
}
Integer appKey;
try {
appKey = Integer.parseInt(appKeyStr.trim());
} catch (NumberFormatException e) {
log.warn("[oapi:departments] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
return null;
}
Integer appId = applicationInfoService.findIdByAppKey(appKey);
if (appId == null) {
log.warn("[oapi:departments] appKey={} not found in application_info, uri={}", appKey,
request.getRequestURI());
}
return appId;
}
}
@@ -56,7 +56,8 @@ public class IdentityController {
} }
/** /**
* 用户全部可选身份(正常态),按 主身份优先 → sortNo → id 排序,供「切换身份」页渲染。 * 用户全部可选身份(正常态),按 主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序,
* 供「切换身份」页渲染。
*/ */
@GetMapping("available") @GetMapping("available")
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) { public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
@@ -10,8 +10,10 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog; import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.common.validation.ValidationGroups; import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config; import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest; import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest; import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest; import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
@@ -90,30 +92,49 @@ public class UserController {
} }
/** /**
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br> * 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* <br> * <br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br> * 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter * ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
* 已保证非空);<br> * 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
* ② 查 application_info 映射到具体 appId;<br> * 口径同现有兜底);<br>
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br> * ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
* <br> * <br>
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br> * 边界:应用解析失败(appCode/appKey 查不到对应应用)→ 直接返回空列表(安全优先,禁止意外
* 返回其他应用的模块)。<br>
* 记账:本参数暴露面=模块名/路径/图标级低敏数据;如需收紧可后续限定仅 BASE 应用调用
* (暂放开,表单中心建设方案 §6.2 已记)。参数用 appCode(字符串)而非整数 appKey:
* 跨环境稳定,与 form-center 分发路由 /app/{appCode}/ 同键。
* *
* @param request 用于取 AppAuthFilter 设置的 appKey * @param request 用于取 AppAuthFilter 设置的 appKey(未传 appCode 时使用)
* @param userId 用户ID(必填) * @param userId 用户ID(必填)
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定, * @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致) * 为空时取用户全部真实归属组,与权限一览一致)
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表) * @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/ */
@GetMapping("modules") @GetMapping("modules")
public List<BoaModule> modules(HttpServletRequest request, public List<BoaModule> modules(HttpServletRequest request,
@RequestParam("userId") Integer userId, @RequestParam("userId") Integer userId,
@RequestParam(value = "schId", required = false) Integer schId) { @RequestParam(value = "schId", required = false) Integer schId,
@RequestParam(value = "appCode", required = false) String appCode) {
if (userId == null) { if (userId == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
Integer appId = resolveCallerAppId(request); Integer appId;
if (appCode != null && !appCode.trim().isEmpty()) {
ApplicationInfo app = applicationInfoService.getByAppCode(appCode.trim());
if (app == null) {
log.warn("[oapi:user:modules] appCode={} not found in application_info, uri={}",
appCode, request.getRequestURI());
return Collections.emptyList();
}
appId = app.getId();
} else {
appId = resolveCallerAppId(request);
}
if (appId == null) { if (appId == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
@@ -240,6 +261,19 @@ public class UserController {
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword()); sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
} }
/**
* 本人联系方式修改(手机号/邮箱):body {userId, phone, email},phone/email 至少一项非空,
* 空白=不修改该字段;只更新 sys_user 的 phone/email 两字段(其余一律不动),
* 手机号/邮箱格式与长度校验、手机号唯一查重在 Service 层。返回更新后的用户
* (getUserInfoWithDeptById,事务提交后缓存已被 UserChangedEvent 驱逐,回读即新值)。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("self/contact")
public BoaUser selfContact(@RequestBody @Validated SysUserContactRequest request) {
sysUserService.updateSelfContact(request.getUserId(), request.getPhone(), request.getEmail());
return userCenterService.getUserInfoWithDeptById(request.getUserId());
}
/** /**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br> * 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br> * 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
/** /**
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br> * 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
*/ */
@NotBlank @Column(name = "custom_id", nullable = true, length = 64)
@Column(name = "custom_id", nullable = false, length = 64)
private String customId; private String customId;
/** /**
@@ -26,6 +26,16 @@ import java.util.List;
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false) @EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class ApplicationInfo extends BaseEntity { public class ApplicationInfo extends BaseEntity {
/** Immutable application incarnation; never derive it from reusable numeric IDs or credentials. */
@Column(name = "source_principal_id", unique = true, updatable = false, length = 36)
private String sourcePrincipalId;
@javax.persistence.PrePersist
void assignSourcePrincipal() {
// Always generate server-side, including when an entity supplied a value.
sourcePrincipalId = java.util.UUID.randomUUID().toString();
}
/** /**
* 应用名称(页面*必填) * 应用名称(页面*必填)
*/ */
@@ -62,6 +72,19 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "app_secret", nullable = false, length = 128) @Column(name = "app_secret", nullable = false, length = 128)
private String appSecret; private String appSecret;
/**
* 技术标识 = projman app.name(如 notice/flow-center),跨系统稳定引用键。<br>
* 跳转查询键(sys_message.jump_app 值域);引用方存的是字符串,改名须盘点引用。
*/
@Column(name = "app_code", length = 32)
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务(判定口径单点见 {@link com.yunhai.userbase.constant.AppNature})。
*/
@Column(name = "app_nature", length = 16)
private String appNature;
//应用前端类型 //应用前端类型
@Convert(converter = ListIntegerJsonConverter.class) @Convert(converter = ListIntegerJsonConverter.class)
@Column(name = "clients", columnDefinition = "json") @Column(name = "clients", columnDefinition = "json")
@@ -91,12 +114,31 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "app_icon") @Column(name = "app_icon")
private String appIcon; private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。<br>
* 不复用 pc_icon/app_icon:那两列存完整 URL,被 oapi BoaApp 出参与 appIconPath() 按 URL 语义消费(方案 §5.2)。
*/
@Column(name = "icon_key", length = 64)
private String iconKey;
/**
* 矢量图标着色(CSS 色值,如 #366EF4)。空 = 端侧默认(场景 token / 分组色)。人工配置值非派生值,落库安全(方案 §5.3)。
*/
@Column(name = "icon_color", length = 16)
private String iconColor;
/** /**
* 公众号应用地址 * 公众号应用地址
*/ */
@Column(name = "official_account_url") @Column(name = "official_account_url")
private String officialAccountUrl; private String officialAccountUrl;
/**
* 微信小程序应用地址
*/
@Column(name = "miniprogram_url")
private String miniprogramUrl;
/** /**
* 钉钉应用地址 * 钉钉应用地址
*/ */
@@ -33,6 +33,21 @@ public class SysGroup extends BaseEntity {
private Integer sort; private Integer sort;
/**
* 启停状态(2026-10-20 部门线 P0 最小批):0=启用 / 1=停用,对齐 flw_dept 枚举。<br>
* 停用只影响「选」(新建选择器过滤),不影响「看」(展示/指南页)与「办」(在途流程解析);
* 当前无消费方,语义在未来部门场景(P1)生效。翻转只允许走 {@code updateStatus} 单列 UPDATE。
*/
@Column(name = "status")
private Integer status = 0;
/**
* 包含子组(2026-10-21 部门线):1=部门列表含下级部门 / 0=仅一级部门,仅学校节点使用。<br>
* 与 status 同为节点自身属性,翻转只允许走 {@code updateIncludeChildren} 单列 UPDATE。
*/
@Column(name = "include_children")
private Integer includeChildren = 1;
// 一对多反向关联中间表 // 一对多反向关联中间表
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY) @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 用户-移动渠道绑定关系(移动端接入 步 3,方案 §3.13)。
*
* <p>openid / unionid ↔ sys_user.id 的绑定属 UserBase 身份域;SDK 登录端点经
* {@code /oapi/channel/**}(应用签名)读写。channel 取值与前端 MobileChannel 对齐:
* wxgzh / miniapp / qywx / dingtalk。</p>
*
* <p>⚠ uk_user_channel 只约束半边:同一 unionid 仍可绑到两个账号(公众号一个、小程序一个),
* 应用层在 {@code ChannelBindService} 绑定时用 idx_unionid 查重补齐,勿仅依赖唯一约束。</p>
*
* @author chenx
* @date 2026/9/25
*/
@Data
@NoArgsConstructor
@Entity
@Table(
name = "sys_user_channel_rel",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"channel", "openid"}),
@UniqueConstraint(columnNames = {"user_id", "channel"})
},
indexes = {@Index(columnList = "unionid")}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserChannelRel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** sys_user.id */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** wxgzh / miniapp / qywx / dingtalk */
@Column(nullable = false, length = 20)
private String channel;
/** 渠道内用户标识 */
@Column(nullable = false, length = 64)
private String openid;
/** 同一开放平台下跨端一致;可空 */
@Column(name = "unionid", length = 64)
private String unionId;
/** 绑定时间(服务端 now() 写入) */
@Column(name = "bound_at", nullable = false)
private LocalDateTime boundAt;
/** 最近一次经该渠道登录时间(token 签发时更新,可空) */
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
}
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门(CUSTOM 侧可指派对象:有成员、有启停、删除为软删)。<br>
* 与 SYSTEM 组织树完全分离:不改组织树、不混入自定义节点;
* 主键独立于 {@code sys_group.id},跨应用引用一律带 {@code departmentType} 消歧,禁止以名称做键。<br>
* 唯一性由 {@code uk_school_category_name_marker(school_id, category_id, name, del_marker)} 保证:
* 同校同分类下未软删的部门名不重复,跨分类与 SYSTEM/CUSTOM 之间允许重名。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartment extends BaseEntity {
/** 学校组 id(冗余自分类,写口由服务端复核,不接受前端传值做授权)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 所属分类(必须同校,移动只允许在本校其他分类之间)。 */
@Column(name = "category_id", nullable = false)
private Integer categoryId;
/** 部门名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/**
* 启用状态:1=启用 / 0=停用。<br>
* ⚠ 语义与 {@code sys_group.status}(0=启用/1=停用)方向相反,严禁混用;接口层统一输出 boolean enabled。
*/
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 软删标记:0=有效 / 1=已软删;软删后成员关系保留在库,由查询侧过滤。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
public boolean isEnabledRow() {
return enabled != null && enabled == 1;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系(引用 {@code sys_user.id},不复制账号)。<br>
* 一个用户可属同校多个 CUSTOM 部门;{@code uk_department_user(department_id, user_id)} 挡有效重复。<br>
* 移除成员=<b>物理删</b>该关系行(本仓无软删惯例,增删审计由 {@code @OperationLog} 承担),
* 因此不会残留「已移除但仍有效」的成员。<br>
* 成员范围=本校教职工且排除学生组(type=8)下的用户,由服务端逐个复核,不接受前端传入即认定。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department_member")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartmentMember extends BaseEntity {
/** 学校组 id(与部门同校,写口服务端复核)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
@Column(name = "department_id", nullable = false)
private Integer departmentId;
@Column(name = "user_id", nullable = false)
private Integer userId;
}
@@ -0,0 +1,56 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门分类(CUSTOM 侧容器层)。<br>
* 分类只是管理容器:<b>没有启停状态、不能配成员</b>,不是可指派的业务部门。<br>
* 唯一性由 {@code uk_school_name_marker(school_id, name, del_marker)} 保证:
* 未软删行 {@code del_marker=0},软删时置为该行 id,因此同校同名只挡有效行;
* {@code name} 永不改写,保证历史解析拿到原名、旧引用不指向新对象。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_dept_category")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDeptCategory extends BaseEntity {
/** 学校组 id(sys_group 中 type=2 节点),与 AccessScopeService.scopeGroupIds() 同域。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 分类名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 软删标记:0=有效 / 1=已软删。删除不是第三种业务状态,与 enabled 维度分离。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键(MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住重复激活)。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/10/21
* @description 部门来源类型(跨业务统一引用的消歧位)。<br>
* 只有 {@code SYSTEM}(原组织架构组)与 {@code CUSTOM}(学校自定义部门)两种,
* <b>不得</b>扩成 FLOW/SVC/AUDIT 等业务类型;引用一律「schoolId + departmentType + departmentId」,禁止以名称做键。
*/
@Getter
@AllArgsConstructor
public enum DepartmentTypeEnum implements IEnum<String> {
SYSTEM("SYSTEM", "系统部门"),
CUSTOM("CUSTOM", "自定义部门");
private final String key;
private final String value;
@Override
public String getKey() {
return key;
}
@Override
public String getValue() {
return value;
}
/** 宽松解析(大小写/空白不敏感);非 SYSTEM/CUSTOM 返回 null,由调用方拒绝。 */
public static DepartmentTypeEnum of(String raw) {
if (raw == null) {
return null;
}
String normalized = raw.trim().toUpperCase();
for (DepartmentTypeEnum type : values()) {
if (type.key.equals(normalized)) {
return type;
}
}
return null;
}
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.application;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 应用×学校单点开通判定结果(2026-10-25 T01 最小只读口)。
* 只回判定所需三件,不透出 secret/别名等管理信息;消费方(form-center/flow-center)
* 把它作为「该应用是否在该校开通」的平台事实第二重证明,fail-closed 消费。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppActivatedVo {
/** 应用主键 ID(稳定主体,与入参一致) */
private Integer appId;
/** 学校组 ID(与入参一致) */
private Integer schoolId;
/** true=该校单点开通集合包含该应用(sys_group_app_rel 口径,不沿祖先) */
private boolean activated;
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
/**
* 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参)。<br>
* 消费方:UserHome(PC GetUserApp 合并注入)与 MobilePortal(移动端桌面 AppItemVo 合并)。<br>
* 只透出 id/iconKey/iconColor 三字段,不含名称/地址等其余应用信息。
*
* @author chenx
*/
@Data
public class AppIconMapVo {
/**
* 应用主键 ID(与 BoaApp.id 同源,消费方按此合并)。
*/
private Integer id;
/**
* 矢量图标键(AppIcon 资产库图标名);null 表示该行不携带,消费方跳过。
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值);null 表示端侧默认。
*/
private String iconColor;
}
@@ -5,6 +5,7 @@ import lombok.Data;
import javax.validation.constraints.NotBlank; import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List; import java.util.List;
/** /**
@@ -34,6 +35,22 @@ public class ApplicationInfoRequest {
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空") @NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空")
private Integer categoryId; private Integer categoryId;
/**
* 技术标识(= projman app.name,如 notice),全库唯一,新增与编辑均必填。<br>
* 编辑必填(2026-09-28 拍板):app_code 是应用身份锚点(消息 jump_app/服务目录键),不许置空;
* 存量 null 历史行不追溯,但编辑提交时必须补填。值域为引用键,改名须盘点引用。
*/
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "技术标识不能为空")
@Size(max = 32, message = "技术标识长度不能超过32")
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务,新增与编辑均必填(2026-09-28 拍板,存量 null 行不追溯)。
*/
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用性质不能为空")
@Size(max = 16, message = "应用性质长度不能超过16")
private String appNature;
/** /**
* 应用描述。<br> * 应用描述。<br>
*/ */
@@ -64,11 +81,26 @@ public class ApplicationInfoRequest {
*/ */
private String appIcon; private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。<br>
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。<br>
*/
private String iconColor;
/** /**
* 公众号应用地址。<br> * 公众号应用地址。<br>
*/ */
private String officialAccountUrl; private String officialAccountUrl;
/**
* 微信小程序应用地址。<br>
*/
private String miniprogramUrl;
/** /**
* 钉钉应用地址。<br> * 钉钉应用地址。<br>
*/ */
@@ -12,6 +12,9 @@ import java.util.List;
*/ */
@Data @Data
public class ApplicationInfoVo { public class ApplicationInfoVo {
/** Stable, non-secret application incarnation used for source isolation. */
private String sourcePrincipalId;
/** /**
* 主键ID。<br> * 主键ID。<br>
*/ */
@@ -47,6 +50,16 @@ public class ApplicationInfoVo {
*/ */
private String appSecret; private String appSecret;
/**
* 技术标识(= projman app.name,如 notice)。<br>
*/
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务。<br>
*/
private String appNature;
/** /**
* 应用前端类型列表。<br> * 应用前端类型列表。<br>
*/ */
@@ -72,11 +85,26 @@ public class ApplicationInfoVo {
*/ */
private String appIcon; private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名);空 = 未配置。<br>
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值);空 = 端侧默认。<br>
*/
private String iconColor;
/** /**
* 公众号应用地址。<br> * 公众号应用地址。<br>
*/ */
private String officialAccountUrl; private String officialAccountUrl;
/**
* 微信小程序应用地址。<br>
*/
private String miniprogramUrl;
/** /**
* 钉钉应用地址。<br> * 钉钉应用地址。<br>
*/ */
@@ -26,10 +26,13 @@ public class AppModuleRequest {
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空") @NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
private String name; private String name;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空") /**
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
*/
private String customId; private String customId;
/** 模块路径(前端路由/资源路径)。<br> */ /** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
private String path; private String path;
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */ /** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
@@ -29,6 +29,13 @@ public class AppRoleAuthzAppVo {
/** 排序号。 */ /** 排序号。 */
private Integer sortNo; private Integer sortNo;
/**
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
* 此时授权落在 sys_app_role_module_rel 的 -appId 哨兵记录上(见 {@code AppLevelModuleId});
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
*/
private Boolean checked = Boolean.FALSE;
/** 该应用下的模块权限树(含勾选回显)。 */ /** 该应用下的模块权限树(含勾选回显)。 */
private List<AppRoleAuthzModuleVo> modules = new ArrayList<>(); private List<AppRoleAuthzModuleVo> modules = new ArrayList<>();
} }
@@ -0,0 +1,30 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(手机号证明模式)开放接口入参(/oapi/channel/bindByPhone)。
* 供小程序「一键手机号」使用:手机号归属由渠道验签器(phoneCode → phone)证明,无密码。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindByPhoneRequest {
/** 渠道码:miniapp */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 渠道验签器解析出的已验证手机号 */
@NotBlank(message = "手机号不能为空")
private String phone;
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(凭证模式)开放接口入参(/oapi/channel/bind)。
* openid/unionId 由 SDK 登录端点从一次性 loginTicket 解出后传入,前端永不直传。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 登录账号(学生学号 / 教师手机号) */
@NotBlank(message = "账号不能为空")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 解绑开放接口入参(/oapi/channel/unbind)。
* account 由 SDK 登录端点从 token payload 解出后传入(服务端定位用户),非客户端自报。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelUnbindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
/** 登录账号(token payload 内 account) */
@NotBlank(message = "账号不能为空")
private String account;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员批量添加/移除请求。<br>
* 服务端<b>逐个</b>复核每个 userId:本校 + 教职工(排除学生组 type=8 下的用户)+ 有效账号;
* 任一不合格整批拒绝(不返回「部分成功」,避免前端以为已生效)。
*/
@Data
public class CustomDepartmentMemberRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotEmpty(message = "成员列表不能为空")
private List<Integer> userIds;
}
@@ -0,0 +1,20 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员列表项(管理端弹窗用,字段最小化:不返回手机号/身份证等敏感列)。
*/
@Data
public class CustomDepartmentMemberVo {
private Integer userId;
/** 账号(sys_user.username)。 */
private String account;
/** 姓名(sys_user.real_name)。 */
private String name;
}
@@ -0,0 +1,21 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门移动请求:只允许移动到<b>本校</b>其他分类;目标分类跨校直接拒绝。
*/
@Data
public class CustomDepartmentMoveRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 目标分类 id。 */
@NotNull(message = "目标分类不能为空")
private Integer categoryId;
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门创建/改名请求。新建默认 enabled=1,成员可为空(先建后配)。
*/
@Data
public class CustomDepartmentRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 归属分类 id:创建必填;改名不生效(以库内归属为准,防止前端传错分类绕过重名范围)。 */
private Integer categoryId;
@NotBlank(message = "部门名称不能为空")
@Size(max = 64, message = "部门名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门排序请求:把某分类下的有效部门按给定 ids 顺序重排(sort_no 从 1 起)。
*/
@Data
public class CustomDepartmentSortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 排序作用范围所在分类 id。 */
private Integer categoryId;
@NotEmpty(message = "部门ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,31 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门列表项(管理端卡片;停用可见、软删默认不出现)。<br>
* {@code enabled} 对外统一 boolean,库内是 1/0 单列,避免与 sys_group.status 的方向混淆。
*/
@Data
public class CustomDepartmentVo {
private Integer id;
private Integer schoolId;
private Integer categoryId;
private String categoryName;
private String name;
private Integer sortNo;
/** 是否启用(true=启用)。 */
private Boolean enabled;
/** 有效成员数。 */
private Long memberCount;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类创建/改名请求。<br>
* schoolId 只作与服务端身份范围的一致性核对,<b>不作为授权依据</b>(授权见
* {@code AccessScopeService#resolveWritableSchoolId})。
*/
@Data
public class CustomDeptCategoryRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotBlank(message = "分类名称不能为空")
@Size(max = 64, message = "分类名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 分类排序请求:把本校有效分类按给定 ids 顺序重排(范式同 SysGroupSortRequest,落库 sort_no 从 1 起)。
*/
@Data
public class CustomDeptCategorySortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 期望顺序的分类 id 列表,必须覆盖本校全部有效分类且不得含别校分类。 */
@NotEmpty(message = "分类ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类列表项(管理端左侧平铺列表;软删分类不出现在列表里)。
*/
@Data
public class CustomDeptCategoryVo {
private Integer id;
private Integer schoolId;
private String name;
private Integer sortNo;
/** 该分类下<b>有效</b>部门数:前端据此对非空分类禁用「删除」。 */
private Long departmentCount;
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录条目(规格书 §4.2 统一响应条目,SYSTEM/CUSTOM 共用同一形状)。<br>
* 引用必须三件套齐全:{@code schoolId + departmentType + departmentId};
* SYSTEM 的 {@code categoryId/categoryName} 恒为 null,层级用 {@code parentId}(照组契约)。
*/
@Data
public class DepartmentEntryVo {
/** 学校组 id。 */
private Integer schoolId;
/** 来源:SYSTEM / CUSTOM(消歧位,禁止靠名称或 ID 单独判断)。 */
private String departmentType;
/** SYSTEM= sys_group.id;CUSTOM= ub_custom_department.id。两域各自独立,可数值相同但语义不同。 */
private Integer departmentId;
private String name;
private Integer categoryId;
private String categoryName;
/** 是否启用(CUSTOM 读 enabled 列,SYSTEM 读 status≠停用)。 */
private Boolean enabled;
/** 是否已软删(仅历史解析口会为 true;SYSTEM 不支持删除,恒 false)。 */
private Boolean deleted;
/** 是否可用于<b>新的</b>选择/绑定:停用或已软删 → false(A10/A12)。 */
private Boolean selectable;
/** SYSTEM=该组直属用户数;CUSTOM=有效成员数。 */
private Long memberCount;
/** 上级部门 id(仅 SYSTEM;withChildren=true 时用于重建层级,CUSTOM 为 null)。 */
private Integer parentId;
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门引用({@code POST /oapi/departments/resolve-batch} 的入参项)。<br>
* 业务侧存的就是这三件套;这里<b>不</b>按名称回填,名称只作为解析结果返回。
*/
@Data
public class DepartmentRefRequest {
@NotNull(message = "schoolId 不能为空")
private Integer schoolId;
/** SYSTEM / CUSTOM(大小写不敏感)。 */
@NotBlank(message = "departmentType 不能为空")
private String departmentType;
@NotNull(message = "departmentId 不能为空")
private Integer departmentId;
}
@@ -30,6 +30,16 @@ public class GroupAuthzAppVo {
private Integer sortNo; private Integer sortNo;
/**
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
* 此时授权落在 sys_group_module_perm 的 module_id=0 记录上(整应用授权);
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
*/
private Boolean checked = Boolean.FALSE;
/** 应用级子组继承:仅当该应用下没有模块时有意义,对应 module_id=0 记录的 inherit_flag。 */
private Boolean inheritFlag = Boolean.FALSE;
/** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */ /** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */
private List<GroupAuthzModuleVo> modules = new ArrayList<>(); private List<GroupAuthzModuleVo> modules = new ArrayList<>();
} }
@@ -32,7 +32,9 @@ public class GroupModuleAuthzItem {
/** /**
* 模块ID;传 0 表示「该应用下全部模块」。<br> * 模块ID;传 0 表示「该应用下全部模块」。<br>
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br> * 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br>
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。 * 而是把 0 展开为应用下每个真实模块各写一条权限记录。<br>
* 特殊情形:应用下<b>一个模块都没有</b>时,改写成 -appId 的整应用哨兵记录
* (见 {@code AppLevelModuleId}),保证这类应用也能被授权与回显。
*/ */
@NotNull(message = "模块ID不能为空(整应用传 0)") @NotNull(message = "模块ID不能为空(整应用传 0)")
private Integer moduleId; private Integer moduleId;
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.sysgroup;
import com.yunhai.platform.utils.model.BoaGroup;
/**
* oapi 组树出参扩展(2026-10-20 部门线 P0 最小批):在平台模型 {@link BoaGroup} 之上透出启停状态。<br>
* <ul>
* <li>{@code status}:0=启用 / 1=停用(sys_group.status 原值,语义=停用只影响「选」不影响「看」与「办」);</li>
* <li>反序列化兼容:字段只增不改,消费方(svc-hall / flow-center)经 starter 注入的 Spring ObjectMapper
* 解析,未知字段默认忽略——不升级消费方 SDK 也能照常工作;需要读 status 时按同名 JSON 键取即可;</li>
* <li>不动 BasePlatform 的 BoaGroup(平台模型加字段要发 SDK 版本,超出最小批)。</li>
* </ul>
*/
public class BoaGroupStatusVo extends BoaGroup {
private Integer status;
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
}
@@ -0,0 +1,19 @@
package com.yunhai.userbase.model.sysgroup;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 设置「包含子组」请求:该校在部门管理页是否连同下级部门一起列出。<br>
* 仅学校节点使用;true=含子组(1)/ false=仅本级(0)。
*/
@Data
public class SysGroupIncludeChildrenRequest {
/** 是否包含子组。 */
@NotNull(message = "includeChildren 不能为空")
private Boolean includeChildren;
}
@@ -16,6 +16,17 @@ public class SysGroupVo {
private String name; private String name;
private Integer type; private Integer type;
private Integer sort; private Integer sort;
/** 启停状态(2026-10-20 部门线 P0):0=启用 / 1=停用;BeanUtils 同名拷贝,null 视为 0 由前端兜底 */
private Integer status;
/** 包含子组(2026-10-21):1=部门列表含下级部门 / 0=仅本级;BeanUtils 同名拷贝,null 视为 1 由前端兜底 */
private Integer includeChildren;
/** 直属成员数(部门表格「人员」列);口径同 oapi 部门目录:sys_user_group_rel 按 groupId 计数,不排除转出。 */
private Long memberCount;
/**
* 直属成员姓名预览,最多 {@code SysGroupService.MEMBER_PREVIEW_LIMIT} 个。<br>
* 完整名单不走这里(部门可能上百人),前端点「查看全部」调 /api/sysuser/List?groupId=xxx。
*/
private List<String> memberNames = new ArrayList<>();
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */ /** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
private List<SysGroupVo> children = new ArrayList<>(); private List<SysGroupVo> children = new ArrayList<>();
} }
@@ -0,0 +1,35 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 本人联系方式修改请求(oapi {@code /oapi/user/self/contact} 专用)。<br>
* 手机号/邮箱至少一项非空(Service 层校验);字段级格式、长度与唯一性校验同在 Service 层
* (手机号口径与 {@code validateProfile} 一致,另做邮箱格式 + 手机号查重)。
*
* @author chenx
*/
@Data
public class SysUserContactRequest {
/**
* 用户ID(必填)。
*/
@NotNull
private Integer userId;
/**
* 手机号(可选;非空时走 1[3-9] 段位格式校验 + 全库唯一查重)。
*/
@Size(max = 20, message = "手机号长度不能超过20字符")
private String phone;
/**
* 邮箱(可选;非空时走邮箱格式校验)。
*/
@Size(max = 100, message = "邮箱长度不能超过100字符")
private String email;
}
@@ -17,7 +17,16 @@ import java.util.List;
* @description * @description
*/ */
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> { public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
@Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000") /** 读取持久化标识快照;禁止查询前自动 flush 掉待校验的实体改名。 */
@Query("SELECT a.appCode FROM ApplicationInfo a WHERE a.id = :id")
@org.springframework.data.jpa.repository.QueryHints(
@javax.persistence.QueryHint(name = "org.hibernate.flushMode", value = "COMMIT"))
String findPersistedAppCodeById(@Param("id") Integer id);
// 基石应用排除 = AppNature.isBaseApp(appKey<=100000 ∪ app_nature='BASE')的 SQL 形态,常量拼接、语义单点见 AppNature。
// app_nature 过渡期可空:`<> 'BASE'` 遇 NULL 求值为 NULL(非 TRUE)会整行漏掉,必须带 IS NULL 分支。
@Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " +
"AND (a.appNature IS NULL OR a.appNature <> 'BASE')")
Integer countByCategoryId(@Param("categoryId") Integer id); Integer countByCategoryId(@Param("categoryId") Integer id);
boolean existsByName(String name); boolean existsByName(String name);
@@ -58,23 +67,39 @@ public interface ApplicationInfoRepository extends JpaRepository<ApplicationInf
/** /**
* 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br> * 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br>
* 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br> * 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br>
* 排除用户中心自身的基石应用(appKey=100000)。 * 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
* *
* @param categoryId 应用类型ID * @param categoryId 应用类型ID
* @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底 * @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底
*/ */
@Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " + @Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " +
"FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 ORDER BY a.sortNo ASC, a.id ASC") "FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " +
"AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.sortNo ASC, a.id ASC")
List<ApplicationInfoSortVo> findSortVoByCategoryId(@Param("categoryId") Integer categoryId); List<ApplicationInfoSortVo> findSortVoByCategoryId(@Param("categoryId") Integer categoryId);
/** /**
* 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br> * 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br>
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br> * 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br>
* 排除用户中心自身的基石应用(appKey=100000)。 * 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
* *
* @return 全部应用的授权投影行 * @return 全部应用的授权投影行
*/ */
@Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " + @Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " +
"FROM ApplicationInfo a WHERE a.appKey > 100000 ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC") "FROM ApplicationInfo a WHERE a.appKey > 100000 " +
"AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC")
List<AppAuthzAppRow> findAllAuthzRows(); List<AppAuthzAppRow> findAllAuthzRows();
/**
* 按技术标识查单个应用(app_code = projman app.name,跳转查询键 /oapi/app/by-code 的数据面)。<br>
*
* @param appCode 技术标识(如 notice)
* @return 匹配的应用实体,无则 null
*/
ApplicationInfo findByAppCode(String appCode);
/** 技术标识唯一性校验(新增用)。 */
boolean existsByAppCode(String appCode);
/** 技术标识唯一性校验(编辑排除自身用)。 */
boolean existsByAppCodeAndIdNot(String appCode, Integer id);
} }
@@ -3,7 +3,9 @@ package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysGroup; import com.yunhai.userbase.entity.SysGroup;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -15,6 +17,22 @@ import java.util.Optional;
*/ */
public interface SysGroupRepository extends JpaRepository<SysGroup, Integer>, JpaSpecificationExecutor<SysGroup> { public interface SysGroupRepository extends JpaRepository<SysGroup, Integer>, JpaSpecificationExecutor<SysGroup> {
/**
* 启停状态单列 UPDATE(2026-10-20 部门线 P0):@Modifying JPQL 绕过实体监听器与审计列,
* 严格只动 sys_group.status 一列(验收断言:翻转前后其余列逐字不变)。
*/
@Modifying
@Query("UPDATE SysGroup g SET g.status = :status WHERE g.id = :id")
int updateStatus(@Param("id") Integer id, @Param("status") Integer status);
/**
* 「包含子组」单列 UPDATE(2026-10-21 部门线):与 {@link #updateStatus} 同语义,
* 严格只动 sys_group.include_children 一列,不触发实体监听器与审计列。
*/
@Modifying
@Query("UPDATE SysGroup g SET g.includeChildren = :includeChildren WHERE g.id = :id")
int updateIncludeChildren(@Param("id") Integer id, @Param("includeChildren") Integer includeChildren);
@Query("SELECT MAX(g.sort) FROM SysGroup g") @Query("SELECT MAX(g.sort) FROM SysGroup g")
Integer findMaxSort(); Integer findMaxSort();
@@ -0,0 +1,39 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.SysUserChannelRel;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* 用户-移动渠道绑定仓储(sys_user_channel_rel,移动端接入 步 3)。
*
* @author chenx
* @date 2026/9/25
*/
public interface SysUserChannelRelRepository extends JpaRepository<SysUserChannelRel, Long>, JpaSpecificationExecutor<SysUserChannelRel> {
/** 按 (channel, openid) 查绑定(uk_channel_openid 唯一) */
Optional<SysUserChannelRel> findByChannelAndOpenid(String channel, String openid);
/** 按用户 + 渠道查绑定(uk_user_channel 唯一):判"该账号在此渠道是否已绑过" */
Optional<SysUserChannelRel> findByUserIdAndChannel(Integer userId, String channel);
/** unionid 应用层查重(uk_user_channel 只约束半边,方案 §3.13 ⚠):同 unionid 已存在的绑定行 */
List<SysUserChannelRel> findByUnionId(String unionId);
/** 解绑(按用户 + 渠道) */
@Modifying
@Query("DELETE FROM SysUserChannelRel r WHERE r.userId = :userId AND r.channel = :channel")
void deleteByUserIdAndChannel(@Param("userId") Integer userId, @Param("channel") String channel);
/** 级联清理:删除用户时清残留绑定行 */
@Modifying
@Query("DELETE FROM SysUserChannelRel r WHERE r.userId = :userId")
void deleteByUserIdBulk(@Param("userId") Integer userId);
}
@@ -0,0 +1,38 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系仓储。
*/
public interface UbCustomDepartmentMemberRepository
extends JpaRepository<UbCustomDepartmentMember, Integer>, JpaSpecificationExecutor<UbCustomDepartmentMember> {
List<UbCustomDepartmentMember> findByDepartmentId(Integer departmentId);
/** 批量取多部门成员(列表页成员数统计用,调用方自行分组计数)。 */
List<UbCustomDepartmentMember> findByDepartmentIdIn(List<Integer> departmentIds);
/** 某用户在 CUSTOM 侧的全部归属(跨部门合法性判定用)。 */
List<UbCustomDepartmentMember> findByUserId(Integer userId);
/**
* 移除成员=物理删关系行(bulk delete 直接发 SQL,避免 Hibernate flush 先 insert 后 delete 撞唯一键,
* 与 {@code SysGroupAppRelRepository#deleteByGroupIdBulk} 同理由)。
*/
@Modifying
@Query("DELETE FROM UbCustomDepartmentMember m WHERE m.departmentId = :departmentId AND m.userId IN :userIds")
int deleteByDepartmentIdAndUserIds(@Param("departmentId") Integer departmentId,
@Param("userIds") List<Integer> userIds);
long countByDepartmentId(Integer departmentId);
}
@@ -0,0 +1,50 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDepartment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门仓储。<br>
* 与 {@code SysGroupRepository#updateStatus} 同范式:启停走单列 {@code @Modifying} JPQL,
* 绕过实体监听器与审计列(这是本仓既定的单列翻转特例);常规增删改仍走实体保存。
*/
public interface UbCustomDepartmentRepository
extends JpaRepository<UbCustomDepartment, Integer>, JpaSpecificationExecutor<UbCustomDepartment> {
/**
* 启用/停用单列 UPDATE:严格只动 enabled 一列。<br>
* ⚠ enabled 语义 1=启用/0=停用,与 sys_group.status 方向相反,调用方不要按 status 传值。
*/
@Modifying
@Query("UPDATE UbCustomDepartment d SET d.enabled = :enabled WHERE d.id = :id")
int updateEnabled(@Param("id") Integer id, @Param("enabled") Integer enabled);
/** 某分类下的有效部门(按排序)。 */
List<UbCustomDepartment> findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(Integer categoryId, Integer isDeleted);
/** 某校全部有效部门(T2 目录/分类计数用)。 */
List<UbCustomDepartment> findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted);
/** 同校同分类下有效同名部门(重名前置校验)。 */
Optional<UbCustomDepartment> findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(
Integer schoolId, Integer categoryId, String name, Integer isDeleted);
boolean existsBySchoolIdAndCategoryIdAndNameAndIsDeleted(
Integer schoolId, Integer categoryId, String name, Integer isDeleted);
/** 分类下有效部门数(非空分类禁止软删,A14)。 */
long countByCategoryIdAndIsDeleted(Integer categoryId, Integer isDeleted);
/** 同分类内当前最大排序号(新建部门排到最后;无有效部门返回 null)。 */
@Query("SELECT MAX(d.sortNo) FROM UbCustomDepartment d WHERE d.categoryId = :categoryId AND d.isDeleted = 0")
Integer findMaxSortNoByCategory(@Param("categoryId") Integer categoryId);
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.repository;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类仓储。<br>
* 查询一律带 {@code isDeleted} 维度:软删行默认不出现,历史解析(T2)单独按 id 取。
*/
public interface UbCustomDeptCategoryRepository
extends JpaRepository<UbCustomDeptCategory, Integer>, JpaSpecificationExecutor<UbCustomDeptCategory> {
/** 分类列表:按学校 + 排序(sort_no 相同时按 id 稳定兜底)。 */
List<UbCustomDeptCategory> findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(Integer schoolId, Integer isDeleted);
/** 同校有效分类是否同名(uk 的服务端前置校验,给友好报错)。 */
Optional<UbCustomDeptCategory> findFirstBySchoolIdAndNameAndIsDeleted(Integer schoolId, String name, Integer isDeleted);
boolean existsBySchoolIdAndNameAndIsDeleted(Integer schoolId, String name, Integer isDeleted);
long countBySchoolIdAndIsDeleted(Integer schoolId, Integer isDeleted);
/** 本校当前最大分类排序号(新建分类排到最后;无有效分类返回 null)。 */
@Query("SELECT MAX(c.sortNo) FROM UbCustomDeptCategory c WHERE c.schoolId = :schoolId AND c.isDeleted = 0")
Integer findMaxSortNoBySchool(@Param("schoolId") Integer schoolId);
}
@@ -39,7 +39,9 @@ public interface IAppCategoryService {
* 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组), * 开放接口专用:按用户 + 客户端类型查询其有权访问的应用列表(按应用类型分组),
* 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。<br> * 并按生效学校合并学校私有别名/图标/排序(2026-09-10)。<br>
* 规则:<br> * 规则:<br>
* ① 权限过滤:仅返回该用户有权访问的应用(复用 {@code IGroupAuthzService.findUserAuthorizedAppIds});<br> * ① 权限过滤(2026-10-12 学校单点开通硬闸门):仅返回<b>生效学校锚点单点开通</b>的应用
* (复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)} 收窄口径,
* 不沿祖先链、角色授权不补充未开通应用;超管保留全量 bypass);<br>
* ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br> * ② 客户端过滤:仅返回支持该客户端类型的应用({@code app.clients} 位掩码与 clientType 有交集;clients 为空视为不限端);<br>
* ③ 不透出 appSecret;<br> * ③ 不透出 appSecret;<br>
* ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖, * ④ 学校别名:名称/图标/排序按 sys_school_app_override / sys_school_category_override 覆盖,
@@ -55,4 +57,21 @@ public interface IAppCategoryService {
*/ */
List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId); List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId);
/**
* 开放接口专用:按学校维度查询该校已开通的应用列表(按应用类型分组,2026-10-10 流程中心应用设置源池)。<br>
* 规则:<br>
* ① 开通口径:<b>仅学校组自身已开通</b>(sys_group_app_rel where group_id=schoolId,开通不继承、
* 不并入子组/角色模块应用)——学校层无用户,这是"学校维度应用清单"的唯一定义;<br>
* ② 基石应用过滤:同 {@link #listBoaAppType}(UserBase 自身 / BASE 应用不出现);<br>
* ③ 客户端过滤:clientType 位掩码(null=不按端过滤);<br>
* ④ 不透出 appSecret;<br>
* ⑤ 学校别名:按该校合并 sys_school_app_override / sys_school_category_override(同 listBoaAppType ④)。<br>
* schoolId 必填且须为合法学校锚点(assertSchoolId),非法抛 DException(code=5)。
*
* @param schoolId 学校组ID(必填)
* @param clientType 客户端类型(null=不按端过滤)
* @return 应用类型 + 该校已开通应用列表(已合并学校别名)
*/
List<BoaAppType> listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType);
} }
@@ -1,6 +1,7 @@
package com.yunhai.userbase.service; package com.yunhai.userbase.service;
import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery; import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest; import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo; import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
@@ -11,6 +12,7 @@ import com.yunhai.userbase.model.application.PushSettingVo;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import java.util.Collection;
import java.util.List; import java.util.List;
/** /**
@@ -28,6 +30,32 @@ public interface IApplicationInfoService {
*/ */
Integer findIdByAppKey(Integer appKey); Integer findIdByAppKey(Integer appKey);
/**
* 按技术标识(app_code = projman app.name)查应用:/oapi/app/by-code 端点的数据面。<br>
*
* @param appCode 技术标识(如 notice)
* @return 应用实体;无则 null
*/
ApplicationInfo getByAppCode(String appCode);
/**
* 按整型 appKey 查应用(查 app_key 列,非主键 id):/oapi/app/by-key 兜底端点的数据面。<br>
* 复用 appKeyToId + appInfo 两级缓存。
*
* @param appKey 应用标识
* @return 应用实体;无则 null
*/
ApplicationInfo getByAppKey(Integer appKey);
/**
* 按应用 id 集合取技术标识 app_code 列表(/oapi/app/authorized-codes 数据面,判权由调用方完成)。<br>
* 顺序跟随入参集合;app_code 为空的行跳过(存量历史行允许 null);结果按首次出现去重。
*
* @param ids 应用主键 id 集合
* @return app_code 列表(可能为空,不为 null)
*/
List<String> listAppCodesByIds(Collection<Integer> ids);
/** /**
* 保存/更新应用信息(写后同步更新缓存)。 * 保存/更新应用信息(写后同步更新缓存)。
*/ */
@@ -106,4 +134,11 @@ public interface IApplicationInfoService {
* @return 推送设置 VO(仅含推送相关字段) * @return 推送设置 VO(仅含推送相关字段)
*/ */
PushSettingVo getPushSetting(Integer id); PushSettingVo getPushSetting(Integer id);
/**
* 全量应用的矢量图标映射(id → iconKey/iconColor),{@code /oapi/app/icon-map} 数据面。<br>
* 仅返回 iconKey 非空的行;供 UserHome / MobilePortal 在既有 BoaApp 列表之上合并注入
* (BoaApp 属 BasePlatform SDK 本期不动,矢量字段走本独立通道补拉)。
*/
List<AppIconMapVo> listIconMap();
} }
@@ -0,0 +1,58 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理服务(CUSTOM 侧,规格书 UB-1)。<br>
* 部门只有「启用/停用」两态;删除是软删(不是第三种状态);成员引用 sys_user.id,不复制账号。
*/
public interface ICustomDepartmentService {
/** 某分类下的有效部门列表(含成员数;停用可见、软删不出现)。 */
List<CustomDepartmentVo> list(Integer schoolId, Integer categoryId);
/** 新建部门:默认 enabled=1,成员可为空(先建后配);同校同分类重名拒绝。 */
CustomDepartmentVo add(CustomDepartmentRequest request);
/** 改名:归属分类以库内为准;同校同分类重名拒绝。 */
CustomDepartmentVo rename(Integer id, CustomDepartmentRequest request);
/** 移动至本校其他分类;目标分类跨校直接拒绝。 */
CustomDepartmentVo move(Integer id, CustomDepartmentMoveRequest request);
/** 同分类内排序:ids 必须全部是本校有效部门。 */
void sort(CustomDepartmentSortRequest request);
/** 启用/停用(单列 UPDATE,两态翻转;软删行不可翻转)。 */
void changeEnabled(Integer id, boolean enabled);
/** 软删部门:成员关系保留在库、由查询侧过滤;不物理删、不释放 id。 */
void softDelete(Integer id);
/** 有效成员列表。 */
List<CustomDepartmentMemberVo> members(Integer id);
/**
* 可添加的候选人员:本校教职工(D4,排除学生组)且<b>尚未</b>在该部门内,按账号/姓名模糊过滤。<br>
* 现有 {@code /api/groupauthz/GroupUsers} 是「组维度」口径、会带出学生组用户,不能直接当候选源,
* 故候选必须走这条与写口同一判定的接口。
*
* @param keyword 账号/姓名关键字,空表示不过滤
*/
List<CustomDepartmentMemberVo> candidateUsers(Integer id, String keyword);
/** 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,已存在的按幂等跳过。 */
void addMembers(Integer id, CustomDepartmentMemberRequest request);
/** 移除成员:物理删关系行(不残留有效成员)。 */
void removeMembers(Integer id, CustomDepartmentMemberRequest request);
}
@@ -0,0 +1,34 @@
package com.yunhai.userbase.service;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理服务(CUSTOM 侧,规格书 UB-1)。<br>
* 分类是纯容器:无启停、无成员;删除只有软删,且<b>禁止任何级联</b>。
*/
public interface ICustomDeptCategoryService {
/** 本校有效分类列表(按 sort_no、id 升序;软删分类不出现)。 */
List<CustomDeptCategoryVo> list(Integer schoolId);
/** 新建分类:同校有效分类重名直接拒绝。 */
CustomDeptCategoryVo add(CustomDeptCategoryRequest request);
/** 分类改名:同校其他有效分类重名拒绝;name 改写只发生在有效行,软删行的历史名称永不覆盖。 */
CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request);
/** 分类排序:ids 必须全部是本校有效分类。 */
void sort(CustomDeptCategorySortRequest request);
/**
* 软删分类:仍存在有效子部门时<b>明确拒绝</b>(A14),不做任何级联删除。<br>
* 软删写 is_deleted/del_marker/deleted_at/deleted_by,保留原 id 且不复用。
*/
void softDelete(Integer id);
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.service;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录契约服务(规格书 UB-2,供 flow-center / svc-hall 等消费方经 {@code /oapi} 读取)。<br>
* 与后台 {@code ICustomDepartmentService} 的关键差别:本服务<b>不感知登录态</b>
* ({@code AccessScopeService} 只服务管理端),授权单位是「调用方应用 × 学校」:
* 应用必须在目标学校已开通({@code sys_group_app_rel}),<b>仅验签不等于可读任意学校</b>(A15)。<br>
* 只读;不写任何数据;本期直查不加缓存。
*/
public interface IDepartmentDirectoryService {
/**
* 可选部门目录:默认只返回<b>启用且未软删</b>的条目。<br>
* type=null 时 SYSTEM + CUSTOM 一并返回;categoryId 只对 CUSTOM 生效;
* withChildren 只对 SYSTEM 生效(true=含部门下级,层级靠 parentId 还原)。
*/
List<DepartmentEntryVo> list(Integer appId, Integer schoolId, DepartmentTypeEnum type,
Integer categoryId, boolean withChildren);
/** CUSTOM 分类目录(按学校授权;不混入 SYSTEM 组织树)。 */
List<CustomDeptCategoryVo> categories(Integer appId, Integer schoolId);
/**
* 批量历史解析:<b>唯一</b>允许返回停用/软删部门的读口(A12)。<br>
* 解析不到(对象不存在,或 SYSTEM 组已被组织模块删除)时按「不可选」返回:name=null、deleted=true。
*/
List<DepartmentEntryVo> resolveBatch(Integer appId, List<DepartmentRefRequest> refs);
/**
* 部门成员解析。{@code forNewAssignment=true}(供新指派)时要求部门启用且未软删,否则拒绝;
* {@code forNewAssignment=false}(已有实例解析)时停用/软删照常返回成员,不改变在途语义(A17)。
*/
List<BoaUser> members(Integer appId, DepartmentTypeEnum type, Integer departmentId, boolean forNewAssignment);
}
@@ -151,6 +151,10 @@ public interface IGroupAuthzService {
* (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组 * (子树内无归属组则仅靠角色授权,不用 schId 冒充本组);schId 为空=取用户全部真实归属组
* (与权限一览「用户全局模式」一致)。<br> * (与权限一览「用户全局模式」一致)。<br>
* 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br> * 超级管理员:不做组/继承判定,直接返回该应用下全部模块(但仍只返回该应用,不会跨应用返回)。<br>
* 学校单点开通硬闸门(2026-10-12):超管短路之后、归属组解析之前,先判定目标应用是否在
* <b>生效学校锚点自身</b>的开通集合({@link #findAuthorizedAppIds(Integer)} 口径,不沿祖先、不并角色);
* 未开通直接返回空列表——角色授权与祖先组开通均不得绕过。生效锚点=显式 schId(过 assertSchoolId)
* → 当前激活身份学校 → 主归属组向上最近锚点;均解析不到=空。<br>
* 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br> * 角色授权并集:最终权限 = 组授权模块 ∪ 启用角色下属于该应用的模块(逻辑与权限一览保持一致)。<br>
* <br> * <br>
* 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表; * 树形语义(2026-09-15 定稿):顶层只放真根(parentId 为空),兄弟按 customId 升序,叶子 children 为空列表;
@@ -209,13 +213,31 @@ public interface IGroupAuthzService {
void setGroupModulePerms(List<GroupModuleAuthzItem> items); void setGroupModulePerms(List<GroupModuleAuthzItem> items);
/** /**
* 查询用户有权访问的应用ID集合(oapi 开放接口 getUserApp 权限过滤用)。<br> * 查询用户有权访问的应用ID集合(历史全局口径:组开通沿祖先 ∪ 启用角色模块所属应用,超管全部)。<br>
* 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br> * 规则与 {@link #hasModulePermission(Integer, Integer)} 对齐:<br>
* - 超级管理员:全部应用;<br> * - 超级管理员:全部应用;<br>
* - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br> * - 普通用户:用户所属组(沿祖先链)开通的应用 ∪ 启用角色模块所属应用。<br>
* <br>
* ⚠️ 2026-10-12 学校单点开通硬闸门后,用户端应用可见性(门户列表 / authorized-codes 判权)一律走学校收窄重载
* {@link #findUserAuthorizedAppIds(Integer, Integer)};本方法现仅由该重载的超管分支内部委托,
* 不再作为对外应用可见性口径。
* *
* @param userId 用户ID * @param userId 用户ID
* @return 有权应用ID列表(无则空列表) * @return 有权应用ID列表(无则空列表)
*/ */
List<Integer> findUserAuthorizedAppIds(Integer userId); List<Integer> findUserAuthorizedAppIds(Integer userId);
/**
* 按生效学校收窄的用户有权应用集合(2026-10-12 学校单点开通硬闸门,门户应用列表链口径)。<br>
* 语义 = <b>生效学校锚点单点开通的应用集合</b>(sys_group_app_rel 本组,不沿祖先、不并角色模块):<br>
* - 超级管理员:保留 bypass,返回全部应用(同无学校版口径);<br>
* - schoolId 非空:过 assertSchoolId 锚点校验后按该校自身单点判定;<br>
* - schoolId 为空:按「当前激活身份学校 → 主归属组向上最近锚点(学校/区域/根)」兜底,
* 区域/根锚点按该锚点自身单点判定;均解析不到(无学校语境)→ 空列表。
*
* @param userId 用户ID(必填)
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
* @return 生效学校单点开通的应用ID列表(无则空列表)
*/
List<Integer> findUserAuthorizedAppIds(Integer userId, Integer schoolId);
} }
@@ -28,6 +28,24 @@ public interface ISysGroupService {
void delete(Integer id); void delete(Integer id);
/**
* 启停状态翻转(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status,不碰其他任何表与列。<br>
* 不判组类型(status 当前无消费方,语义只在未来部门场景生效);删除行为一字不动(沿用 deleteInternal 现行规则)。
*
* @param id 组ID
* @param disabled true=停用(status=1)/ false=启用(status=0)
*/
void changeStatus(Integer id, boolean disabled);
/**
* 设置「包含子组」(2026-10-21 部门线):单列 UPDATE sys_group.include_children,不碰其他任何表与列。<br>
* 仅学校节点使用,语义是「部门管理页的部门列表是否连同下级部门一起展示」。
*
* @param id 学校组ID
* @param include true=含子组(1)/ false=仅本级(0)
*/
void changeIncludeChildren(Integer id, boolean include);
/** /**
* 开放接口专用:在学校(组)范围内删除组。<br> * 开放接口专用:在学校(组)范围内删除组。<br>
* 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br> * 与 {@link #delete(Integer)} 删除逻辑一致,但额外强制边界:<br>
@@ -11,6 +11,8 @@ import org.springframework.web.multipart.MultipartFile;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import com.yunhai.userbase.entity.SysUser;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.List; import java.util.List;
@@ -25,6 +27,13 @@ public interface ISysUserService {
/** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */ /** 新增用户:校验密码/手机号/身份证,BCrypt 加密,并写入组用户关联。返回创建后的用户ID。 */
Integer add(SysUserRequest request); Integer add(SysUserRequest request);
/**
* 按 id 批量取用户实体(窄用途:管理面聚合页回显 id/账号/姓名)。<br>
* 只读;入参去重、去 null、截断到 200 个;查无返回空列表。调用方(控制器)自行投影白名单字段,
* 不得把实体直接序列化出站(实体含手机号/身份证等敏感列)。
*/
List<SysUser> listByIds(List<Integer> ids);
/** /**
* 编辑用户(管理端);密码为空时不修改密码。<br> * 编辑用户(管理端);密码为空时不修改密码。<br>
* 用户身份按「schoolId 该校全量覆盖」写(勾选框语义);带了 schoolId 却不带 roleIds 直接报错, * 用户身份按「schoolId 该校全量覆盖」写(勾选框语义);带了 schoolId 却不带 roleIds 直接报错,
@@ -209,6 +218,19 @@ public interface ISysUserService {
*/ */
void changePassword(Integer userId, String oldPassword, String newPassword); void changePassword(Integer userId, String oldPassword, String newPassword);
/**
* 修改本人联系方式(手机号/邮箱,oapi 开放接口 {@code /oapi/user/self/contact} 专用)。<br>
* 只写 sys_user 的 phone/email 两字段,其余字段一律不动;phone/email 至少一项非空,
* 空白字段视为「不修改该字段」(不写库不清空);手机号改值时做全库唯一查重(phone 是
* byPhone 身份维度,重复会破坏手机号找人)。更新后发 UserChangedEvent 精确驱逐 boaUser
* 缓存(含旧 phone: 维度 key 提示),调用方紧跟着的 byId 回读即新值。
*
* @param userId 用户ID(目标用户本人)
* @param phone 新手机号(可空=不改)
* @param email 新邮箱(可空=不改)
*/
void updateSelfContact(Integer userId, String phone, String email);
/** /**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 → * 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储至 avatar 子目录 →
* 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。 * 写入 profile_picture。oapi 开放接口专用;返回新头像完整 Web URL。
@@ -7,6 +7,7 @@ import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.config.CacheNames; import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.constant.AppNature;
import com.yunhai.userbase.entity.AppCategory; import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.entity.SysUserGroupRel;
@@ -197,7 +198,10 @@ public class AppCategoryService implements IAppCategoryService {
public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) { public List<BoaAppType> listBoaAppType(Integer userId, ClientEnum clientType, Integer schoolId) {
// 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并 // 生效学校先行解析:非法锚点/非所属学校直接抛 DException(异常不入缓存),null=无学校语境不合并
Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId); Integer effectiveSchool = resolveEffectiveSchool(userId, schoolId);
Set<Integer> authorizedAppIds = new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId)); // 权限过滤走学校单点开通收窄口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合,
// 角色授权不再补充「学校未开通」的应用;锚点解析不到(无学校语境)= 空集合。
Set<Integer> authorizedAppIds =
new HashSet<>(groupAuthzService.findUserAuthorizedAppIds(userId, effectiveSchool));
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc(); List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List<ApplicationInfo> apps = applicationInfoRepository.findAll(); List<ApplicationInfo> apps = applicationInfoRepository.findAll();
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo)) apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
@@ -220,6 +224,14 @@ public class AppCategoryService implements IAppCategoryService {
type.setName(category.getName()); type.setName(category.getName());
List<BoaApp> boaApps = new ArrayList<>(); List<BoaApp> boaApps = new ArrayList<>();
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) { for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
// 基石应用过滤(口径单点 = AppNature.isBaseApp,与管理端 ①②③⑤ 同侧):
// UserBase 自身 / app_nature=BASE(UserHome、移动门户)不参与用户端应用列表,
// 否则超管(findUserAuthorizedAppIds 返回全量)会把基石带进门户 /apps。
// 与 manage 端列表(ApplicationInfoService.select,仅同步 isUserbaseSelf 语义、
// 保留基石供重置密钥)口径不同,勿合并。
if (AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
continue;
}
// 用户权限过滤 // 用户权限过滤
if (!authorizedAppIds.contains(app.getId())) { if (!authorizedAppIds.contains(app.getId())) {
continue; continue;
@@ -236,6 +248,64 @@ public class AppCategoryService implements IAppCategoryService {
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool); return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, effectiveSchool);
} }
/**
* 学校维度应用清单(2026-10-10,GET /oapi/app/list-by-school 读路径;流程中心 flw_app 源池)。<br>
* 开通口径 = 学校组<b>单点</b>已开通(findAuthorizedAppIds(schId),不沿祖先/不并角色模块):
* 学校层无用户,"学校开了什么"就是 sys_group_app_rel 的单点事实——学校把应用只开给子组时
* 本端点看不到它,属口径自然结果(组开通继承的是模块权限池,不是学校清单)。<br>
* 骨架复用 {@link #listBoaAppType}(分类分组/基石过滤/端过滤/convertToBoaApp 不透 secret/
* applyAlias 别名合并),仅权限集合来源不同;返回前合并学校别名。<br>
* 缓存 key 规则:{@code 's:'+schoolId+':c:'+(clientType?'all')}——学校 id 不可枚举,
* 一律整清(授权/组/应用/别名事件)+ 60s TTL 自愈(同 USER_APP_LIST 显式学校后缀策略)。
*/
@Override
@Cacheable(value = CacheNames.SCHOOL_APP_LIST,
key = "'s:' + #schoolId + ':c:' + (#clientType == null ? 'all' : #clientType.name())",
sync = true)
public List<BoaAppType> listBoaAppTypeBySchool(Integer schoolId, ClientEnum clientType) {
if (schoolId == null) {
throw new DException("schoolId 必填");
}
sysGroupService.assertSchoolId(schoolId);
Set<Integer> openedAppIds = new HashSet<>(groupAuthzService.findAuthorizedAppIds(schoolId));
List<AppCategory> categories = appCategoryRepository.findAllByOrderBySortNoAsc();
List<ApplicationInfo> apps = applicationInfoRepository.findAll();
apps.sort(Comparator.comparing(ApplicationInfo::getSortNo, Comparator.nullsLast(Integer::compareTo))
.thenComparing(ApplicationInfo::getId, Comparator.nullsLast(Integer::compareTo)));
Map<Integer, List<ApplicationInfo>> appMap = new LinkedHashMap<>();
for (ApplicationInfo app : apps) {
if (app.getCategoryId() == null) {
continue;
}
appMap.computeIfAbsent(app.getCategoryId(), k -> new ArrayList<>()).add(app);
}
Map<Integer, Integer> categoryBaseSortNo = new HashMap<>();
List<BoaAppType> result = new ArrayList<>();
for (AppCategory category : categories) {
categoryBaseSortNo.put(category.getId(), category.getSortNo());
BoaAppType type = new BoaAppType();
type.setId(category.getId());
type.setName(category.getName());
List<BoaApp> boaApps = new ArrayList<>();
for (ApplicationInfo app : appMap.getOrDefault(category.getId(), Collections.emptyList())) {
if (AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
continue;
}
// 学校组单点开通过滤(与用户维度的权限过滤唯一差异)
if (!openedAppIds.contains(app.getId())) {
continue;
}
if (clientType != null && !containsClient(app.getClients(), clientType.getKey())) {
continue;
}
boaApps.add(convertToBoaApp(app));
}
type.setApps(boaApps);
result.add(type);
}
return schoolAppOverrideService.applyAlias(result, categoryBaseSortNo, schoolId);
}
/** /**
* oapi 生效学校三级兜底:<br> * oapi 生效学校三级兜底:<br>
* ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组), * ① 显式 {@code schoolId}——复用开放接口公共锚点校验 {@code assertSchoolId}(非空/存在/须学校组),
@@ -295,6 +365,7 @@ public class AppCategoryService implements IAppCategoryService {
boa.setMobiIcon(app.getAppIcon()); boa.setMobiIcon(app.getAppIcon());
boa.setClients(toClientMask(app.getClients())); boa.setClients(toClientMask(app.getClients()));
boa.setWxgzhAddress(app.getOfficialAccountUrl()); boa.setWxgzhAddress(app.getOfficialAccountUrl());
boa.setMiniprogramAddress(app.getMiniprogramUrl());
boa.setDingAddress(app.getDingtalkUrl()); boa.setDingAddress(app.getDingtalkUrl());
return boa; return boa;
} }
@@ -58,12 +58,16 @@ public class AppModuleService implements IAppModuleService {
if (!applicationInfoRepository.existsById(request.getAppId())) { if (!applicationInfoRepository.existsById(request.getAppId())) {
throw new DException("所属应用不存在"); throw new DException("所属应用不存在");
} }
if (appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), request.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
AppModule entity = new AppModule(); AppModule entity = new AppModule();
BeanUtils.copyProperties(request, entity); BeanUtils.copyProperties(request, entity);
entity.setId(null); entity.setId(null);
// 按钮权限型子模块可不带编码:空白归一为 null,仅在有值时做应用内唯一校验
entity.setCustomId(normalizeBlankToNull(request.getCustomId()));
if (entity.getCustomId() != null
&& appModuleRepository.existsByAppIdAndCustomId(request.getAppId(), entity.getCustomId())) {
throw new DException("自定义ID在该应用下已存在");
}
entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setLevel(resolveLevel(request.getAppId(), request.getParentId())); entity.setLevel(resolveLevel(request.getAppId(), request.getParentId()));
// sortNo 默认为空(由前端编辑时带入),不自动递增 // sortNo 默认为空(由前端编辑时带入),不自动递增
appModuleRepository.save(entity); appModuleRepository.save(entity);
@@ -77,13 +81,15 @@ public class AppModuleService implements IAppModuleService {
Objects.requireNonNull(request, "模块编辑入参不能为空"); Objects.requireNonNull(request, "模块编辑入参不能为空");
AppModule entity = appModuleRepository.findById(id) AppModule entity = appModuleRepository.findById(id)
.orElseThrow(() -> new RuntimeException("模块不存在: " + id)); .orElseThrow(() -> new RuntimeException("模块不存在: " + id));
if (appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), request.getCustomId(), id)) { // 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
String customId = normalizeBlankToNull(request.getCustomId());
if (customId != null
&& appModuleRepository.existsByAppIdAndCustomIdAndIdNot(entity.getAppId(), customId, id)) {
throw new DException("自定义ID在该应用下已存在"); throw new DException("自定义ID在该应用下已存在");
} }
// 可编辑字段:name/customId/path/icon/sortNo;id、appId 不可改
entity.setName(request.getName()); entity.setName(request.getName());
entity.setCustomId(request.getCustomId()); entity.setCustomId(customId);
entity.setPath(request.getPath()); entity.setPath(normalizeBlankToNull(request.getPath()));
entity.setIcon(request.getIcon()); entity.setIcon(request.getIcon());
entity.setSortNo(request.getSortNo()); entity.setSortNo(request.getSortNo());
// parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级 // parentId 变更即移动子树:校验层级上限与环,并级联刷新后代层级
@@ -278,6 +284,19 @@ public class AppModuleService implements IAppModuleService {
} }
} }
/**
* 按钮权限型子模块可不带编码/路径:空白(null/空串/纯空格)统一归一为 null。<br>
* customId 归一是硬性要求:'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下每个应用只能有一条,
* 多个按钮型子模块会互相撞键;NULL 在 MySQL 唯一索引中可并存。
*/
private static String normalizeBlankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private AppModuleVo convertToVo(AppModule entity) { private AppModuleVo convertToVo(AppModule entity) {
AppModuleVo vo = new AppModuleVo(); AppModuleVo vo = new AppModuleVo();
BeanUtils.copyProperties(entity, vo); BeanUtils.copyProperties(entity, vo);
@@ -9,6 +9,8 @@ import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser; import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserAppRoleRel; import com.yunhai.userbase.entity.SysUserAppRoleRel;
import com.yunhai.userbase.entity.SysUserGroupRel; import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.constant.AppLevelModuleId;
import com.yunhai.userbase.constant.AppNature;
import com.yunhai.userbase.event.AppAuthzChangedEvent; import com.yunhai.userbase.event.AppAuthzChangedEvent;
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo; import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo; import com.yunhai.userbase.model.approle.AppRoleAuthzModuleVo;
@@ -68,10 +70,6 @@ import java.util.Set;
@Service @Service
public class AppRoleService implements IAppRoleService { public class AppRoleService implements IAppRoleService {
/** 用户中心自身基石应用的 appKey(application_info.appKey 从 100000 起自增,100000 即用户中心平台,
* 无应用类型;全应用授权范围须排除它——与 ApplicationInfoRepository「appKey > 100000」口径一致。 */
private static final int USER_CENTER_APP_KEY = 100000;
/** 组根节点的 parentId 约定值(与 GroupAuthzService.ROOT_PARENT_ID 同语义)。 */ /** 组根节点的 parentId 约定值(与 GroupAuthzService.ROOT_PARENT_ID 同语义)。 */
private static final int ROOT_PARENT_ID = 0; private static final int ROOT_PARENT_ID = 0;
@@ -206,13 +204,14 @@ public class AppRoleService implements IAppRoleService {
Objects.requireNonNull(roleId, "角色ID不能为空"); Objects.requireNonNull(roleId, "角色ID不能为空");
SysAppRole role = sysAppRoleRepository.findById(roleId) SysAppRole role = sysAppRoleRepository.findById(roleId)
.orElseThrow(() -> new RuntimeException("角色不存在: " + roleId)); .orElseThrow(() -> new RuntimeException("角色不存在: " + roleId));
// 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除用户中心自身基石应用, // 授权范围:普通角色仅本应用;全应用角色为系统全部应用(排除基石应用——口径 =
// 它无应用类型,前端按分类分组时会落进「未分类」组展示,不属于可授权业务应用) // AppNature.isBaseApp:UserBase 自身无应用类型会落「未分类」组,显式标记 BASE 的
// 基建应用同样不属于可授权业务应用)
List<ApplicationInfo> apps; List<ApplicationInfo> apps;
if (Boolean.TRUE.equals(role.getAllAppFlag())) { if (Boolean.TRUE.equals(role.getAllAppFlag())) {
apps = new ArrayList<>(); apps = new ArrayList<>();
for (ApplicationInfo app : applicationInfoRepository.findAll()) { for (ApplicationInfo app : applicationInfoRepository.findAll()) {
if (app.getAppKey() == null || app.getAppKey() > USER_CENTER_APP_KEY) { if (!AppNature.isBaseApp(app.getAppKey(), app.getAppNature())) {
apps.add(app); apps.add(app);
} }
} }
@@ -244,8 +243,12 @@ public class AppRoleService implements IAppRoleService {
AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo(); AppRoleAuthzAppVo appVo = new AppRoleAuthzAppVo();
BeanUtils.copyProperties(app, appVo); BeanUtils.copyProperties(app, appVo);
List<AppModule> modules = appModuleMap.get(app.getId()); List<AppModule> modules = appModuleMap.get(app.getId());
if (modules != null) { if (modules != null && !modules.isEmpty()) {
appVo.setModules(buildModuleTree(modules, checkedModuleIds)); appVo.setModules(buildModuleTree(modules, checkedModuleIds));
} else {
// 无子模块应用:授权挂在 -appId 的整应用哨兵记录上(见 AppLevelModuleId),
// 否则这类应用勾选态永远无回显(勾了刷新就丢)。
appVo.setChecked(checkedModuleIds.contains(AppLevelModuleId.of(app.getId())));
} }
result.add(appVo); result.add(appVo);
} }
@@ -268,6 +271,18 @@ public class AppRoleService implements IAppRoleService {
moduleMap.put(m.getId(), m); moduleMap.put(m.getId(), m);
} }
for (Integer moduleId : moduleIds) { for (Integer moduleId : moduleIds) {
// 整应用授权哨兵(-appId):不走模块校验,改为校验应用存在性与角色授权范围
Integer appLevelAppId = AppLevelModuleId.resolveAppId(moduleId);
if (appLevelAppId != null) {
if (!applicationInfoRepository.existsById(appLevelAppId)) {
throw new DException("应用不存在: " + appLevelAppId);
}
if (!Boolean.TRUE.equals(role.getAllAppFlag())
&& !Objects.equals(appLevelAppId, role.getAppId())) {
throw new DException("应用 " + appLevelAppId + " 不属于该角色所属应用,普通角色不能跨应用授权");
}
continue;
}
AppModule m = moduleMap.get(moduleId); AppModule m = moduleMap.get(moduleId);
if (m == null) { if (m == null) {
throw new DException("模块不存在: " + moduleId); throw new DException("模块不存在: " + moduleId);
@@ -3,6 +3,7 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.AppModule; import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.entity.ApplicationInfo; import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysAppRole; import com.yunhai.userbase.entity.SysAppRole;
import com.yunhai.userbase.constant.AppNature;
import com.yunhai.userbase.repository.AppModuleRepository; import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository; import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository; import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
@@ -17,6 +18,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.Cacheable; import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoQuery; import com.yunhai.userbase.model.application.ApplicationInfoQuery;
import com.yunhai.userbase.model.application.ApplicationInfoRequest; import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.model.application.ApplicationInfoSortVo; import com.yunhai.userbase.model.application.ApplicationInfoSortVo;
@@ -36,8 +38,11 @@ import javax.persistence.criteria.Predicate;
import javax.persistence.criteria.Root; import javax.persistence.criteria.Root;
import javax.transaction.Transactional; import javax.transaction.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set;
import java.util.UUID; import java.util.UUID;
@Service @Service
@@ -98,11 +103,52 @@ public class ApplicationInfoService implements IApplicationInfoService {
return app == null ? null : app.getId(); return app == null ? null : app.getId();
} }
@Override
public ApplicationInfo getByAppCode(String appCode) {
if (StringUtils.isBlank(appCode)) {
return null;
}
return applicationInfoRepository.findByAppCode(appCode);
}
/**
* 查的是 app_key 列而非主键 id(id ≠ app_key:流程中心 appKey=100009 主键是 100011),
* 走 findIdByAppKey + getById 两级缓存而非直查。
*/
@Override
public ApplicationInfo getByAppKey(Integer appKey) {
if (appKey == null) {
return null;
}
Integer id = findIdByAppKey(appKey);
return id == null ? null : getById(id);
}
@Override
public List<String> listAppCodesByIds(Collection<Integer> ids) {
List<String> codes = new ArrayList<>();
if (ids == null || ids.isEmpty()) {
return codes;
}
Set<String> seen = new HashSet<>();
for (ApplicationInfo info : applicationInfoRepository.findAllById(ids)) {
String code = info.getAppCode();
if (StringUtils.isNotBlank(code) && seen.add(code)) {
codes.add(code);
}
}
return codes;
}
/** /**
* 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。 * 通用保存:方法返回后发布变更事件,缓存失效由 AFTER_COMMIT 监听器统一执行。
*/ */
@Override @Override
public ApplicationInfo save(ApplicationInfo info) { public ApplicationInfo save(ApplicationInfo info) {
if (info.getId() != null) {
requireUnchangedAppCode(applicationInfoRepository.findPersistedAppCodeById(info.getId()),
info.getAppCode());
}
ApplicationInfo saved = applicationInfoRepository.save(info); ApplicationInfo saved = applicationInfoRepository.save(info);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
return saved; return saved;
@@ -128,6 +174,11 @@ public class ApplicationInfoService implements IApplicationInfoService {
if (applicationInfoRepository.existsByName(request.getName())) { if (applicationInfoRepository.existsByName(request.getName())) {
throw new DException("应用名称已存在"); throw new DException("应用名称已存在");
} }
// 技术标识唯一性(app_code = projman app.name,跳转查询键)
if (StringUtils.isNotBlank(request.getAppCode())
&& applicationInfoRepository.existsByAppCode(request.getAppCode())) {
throw new DException("技术标识已存在");
}
ApplicationInfo entity = new ApplicationInfo(); ApplicationInfo entity = new ApplicationInfo();
BeanUtils.copyProperties(request, entity); BeanUtils.copyProperties(request, entity);
entity.setId(null); entity.setId(null);
@@ -152,11 +203,42 @@ public class ApplicationInfoService implements IApplicationInfoService {
} }
ApplicationInfo entity = applicationInfoRepository.findById(id) ApplicationInfo entity = applicationInfoRepository.findById(id)
.orElseThrow(() -> new RuntimeException("应用不存在: " + id)); .orElseThrow(() -> new RuntimeException("应用不存在: " + id));
BeanUtils.copyProperties(request, entity, "id", "appKey", "appSecret"); // 空值仍按既有部分提交语义处理;非空已登记标识不可真正改名。
if (StringUtils.isNotBlank(request.getAppCode())) {
requireUnchangedAppCode(entity.getAppCode(), request.getAppCode());
}
if (StringUtils.isNotBlank(request.getAppCode())
&& applicationInfoRepository.existsByAppCodeAndIdNot(request.getAppCode(), id)) {
throw new DException("技术标识已存在");
}
// appCode/appNature/iconKey/iconColor 空值不回写:部分提交场景(学校覆盖线只传 4 字段)copyProperties
// 会把 null 覆盖进主表;app_code 是引用键,清空即引用失效,仅显式提供时才更新。
// iconKey/iconColor 采用「未携带(null)=不改,携带空串=清除,携带值=更新」三分语义:
// 未启用矢量选择器的提交方不送该键即零影响;管理端全量表单显式清空即解除矢量渲染回到原回退链
BeanUtils.copyProperties(request, entity,
"id", "appKey", "appSecret", "appCode", "appNature", "iconKey", "iconColor");
if (StringUtils.isNotBlank(request.getAppCode())) {
entity.setAppCode(request.getAppCode());
}
if (StringUtils.isNotBlank(request.getAppNature())) {
entity.setAppNature(request.getAppNature());
}
if (request.getIconKey() != null) {
entity.setIconKey(StringUtils.trimToNull(request.getIconKey()));
}
if (request.getIconColor() != null) {
entity.setIconColor(StringUtils.trimToNull(request.getIconColor()));
}
ApplicationInfo saved = applicationInfoRepository.save(entity); ApplicationInfo saved = applicationInfoRepository.save(entity);
eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved)); eventPublisher.publishEvent(ApplicationInfoCacheChangedEvent.upsert(this, saved));
} }
private static void requireUnchangedAppCode(String persisted, String proposed) {
if (StringUtils.isNotBlank(persisted) && !Objects.equals(persisted, proposed)) {
throw new DException("已登记的应用技术标识 appCode 不可修改");
}
}
@Override @Override
@Transactional @Transactional
// AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理); // AI-FIX: 删除应用同时级联删除其下全部模块(逻辑外键 appId,无物理 FK,需手动清理);
@@ -218,7 +300,9 @@ public class ApplicationInfoService implements IApplicationInfoService {
@Override @Override
public Predicate toPredicate(Root<ApplicationInfo> root, CriteriaQuery<?> cq, CriteriaBuilder cb) { public Predicate toPredicate(Root<ApplicationInfo> root, CriteriaQuery<?> cq, CriteriaBuilder cb) {
List<Predicate> predicates = new ArrayList<Predicate>(); List<Predicate> predicates = new ArrayList<Predicate>();
predicates.add(cb.greaterThan(root.get("appKey"), 100000)); // ④ 口径 = AppNature.isUserbaseSelf(仅排 UserBase 自身, Criteria 无法调 Java 方法,此为常量形态);
// 显式基石(app_nature=BASE)保留在列表供编辑/重置密钥——与 ①②③⑤ 的 isBaseApp 全排除口径不同,勿合并
predicates.add(cb.greaterThan(root.get("appKey"), AppNature.USER_CENTER_APP_KEY));
if (query != null) { if (query != null) {
if (StringUtils.isNotEmpty(query.getName())) { if (StringUtils.isNotEmpty(query.getName())) {
String keyword = query.getName(); String keyword = query.getName();
@@ -234,6 +318,15 @@ public class ApplicationInfoService implements IApplicationInfoService {
predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId())); predicates.add(cb.equal(root.get("categoryId"), query.getCategoryId()));
} }
} }
// 类别视图的排序只能内嵌在 spec(NULL 沉底须 case 表达式,Pageable 的 Sort 表达不了)。
// 生效前提:控制器对该分支传 Sort.unsorted(),否则 Pageable 排序会在此之后 cq.orderBy 整体覆盖;
// count 查询不受影响(Spring Data 会剥掉 spec 带入的 orderBy)
if (query != null && query.getCategoryId() != null) {
cq.orderBy(
cb.asc(cb.selectCase().when(cb.isNull(root.get("sortNo")), 1).otherwise(0)),
cb.asc(root.get("sortNo")),
cb.asc(root.get("id")));
}
return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction(); return cq.where(predicates.toArray(new Predicate[predicates.size()])).getRestriction();
} }
}; };
@@ -338,4 +431,20 @@ public class ApplicationInfoService implements IApplicationInfoService {
vo.setPushSecret(entity.getPushSecret()); vo.setPushSecret(entity.getPushSecret());
return vo; return vo;
} }
}
@Override
public List<AppIconMapVo> listIconMap() {
List<AppIconMapVo> result = new ArrayList<>();
for (ApplicationInfo app : applicationInfoRepository.findAll()) {
if (StringUtils.isBlank(app.getIconKey())) {
continue; // 未配置矢量键的行不下发,消费方零合并成本
}
AppIconMapVo vo = new AppIconMapVo();
vo.setId(app.getId());
vo.setIconKey(app.getIconKey());
vo.setIconColor(app.getIconColor());
result.add(vo);
}
return result;
}
}
@@ -92,8 +92,10 @@ public class BoaUserLoader {
boa.setIdcard(user.getIdcard()); boa.setIdcard(user.getIdcard());
boa.setPhone(user.getPhone()); boa.setPhone(user.getPhone());
boa.setProfilePicture(user.getProfilePicture()); boa.setProfilePicture(user.getProfilePicture());
// 用户表无 email / gender 列,置 null // email 列已存在(SysUser.email,管理端编辑/批量导入均已读写):
boa.setEmail(null); // 本人联系方式修改(oapi self/contact)后回读 BoaUser 须携带新 email,旧注释「用户表无 email 列」已过时;
// gender 列虽在实体上,但当前无写入来源,保持置 null
boa.setEmail(user.getEmail());
boa.setGender(null); boa.setGender(null);
boa.setType(user.getType() != null ? user.getType().getKey() : null); boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用) // 状态语义对齐:本表 disabled(NORMAL=0 正常 / DISABLED=1 禁用) -> BoaUser(1 正常 / 0 禁用)
@@ -64,7 +64,8 @@ public class CacheInvalidationListener {
clearQuietly(CacheNames.APP_INFO); clearQuietly(CacheNames.APP_INFO);
clearQuietly(CacheNames.APP_KEY_TO_ID); clearQuietly(CacheNames.APP_KEY_TO_ID);
clearQuietly(CacheNames.USER_APP_LIST); clearQuietly(CacheNames.USER_APP_LIST);
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList", clearQuietly(CacheNames.SCHOOL_APP_LIST);
log.info("[Cache失效] 应用变更 appKey={} type={} → 清 appInfo/appKeyToId/userAppList/schoolAppList",
event.getAppKey(), event.getType()); event.getAppKey(), event.getType());
} }
@@ -77,8 +78,9 @@ public class CacheInvalidationListener {
clearQuietly(CacheNames.BOA_USER_NULL); clearQuietly(CacheNames.BOA_USER_NULL);
clearQuietly(CacheNames.IDENTITY_CURRENT); clearQuietly(CacheNames.IDENTITY_CURRENT);
clearQuietly(CacheNames.USER_APP_LIST); clearQuietly(CacheNames.USER_APP_LIST);
clearQuietly(CacheNames.SCHOOL_APP_LIST);
clearQuietly(CacheNames.USER_GROUP_REL); clearQuietly(CacheNames.USER_GROUP_REL);
log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/userGroupRel", log.info("[Cache失效] 组变更 op={} → 驱逐 groupSnapshot,清 boaUser/identityCurrent/userAppList/schoolAppList/userGroupRel",
event.getSourceOp()); event.getSourceOp());
} }
@@ -132,7 +134,8 @@ public class CacheInvalidationListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onAppAuthzChanged(AppAuthzChangedEvent event) { public void onAppAuthzChanged(AppAuthzChangedEvent event) {
clearQuietly(CacheNames.USER_APP_LIST); clearQuietly(CacheNames.USER_APP_LIST);
log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList", event.getSourceOp()); clearQuietly(CacheNames.SCHOOL_APP_LIST);
log.info("[Cache失效] 应用授权变更 op={} → 清 userAppList/schoolAppList", event.getSourceOp());
} }
// ===================== 驱逐工具(error 可观测,TTL 自愈兜底) ===================== // ===================== 驱逐工具(error 可观测,TTL 自愈兜底) =====================
@@ -0,0 +1,146 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserChannelRel;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysUserChannelRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
/**
* 移动端渠道绑定服务(sys_user_channel_rel,移动端接入 步 3,方案 §3.13)。<br>
* <br>
* openid / unionid ↔ userId 的绑定属 UserBase 身份域;SDK 登录端点(门户/各子应用)经
* {@code /oapi/channel/**} 应用签名调用本服务。绑定一律以服务端解析出的身份为准
* (openid/unionid 来自一次性 loginTicket,账号密码/手机号在服务端校验),前端永不直传 openid。<br>
* <br>
* 唯一性三层防线:<br>
* ① uk_channel_openid:一个 openid 同渠道只能绑一个账号(DB 兜底 + 应用层前置校验友好报错);<br>
* ② uk_user_channel:一个账号同渠道只能绑一个 openid(换绑须先解绑);<br>
* ③ unionid 应用层查重:uk_user_channel 只约束半边(同一 unionid 仍可绑两个账号——公众号一个、
* 小程序一个),用 idx_unionid 在绑定时补齐(方案 §3.13 ⚠)。
*
* @author chenx
* @date 2026/9/25
*/
@Service
public class ChannelBindService {
@Autowired
private SysUserChannelRelRepository relRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private UserCenterService userCenterService;
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 按 (channel, openid) 查已绑定用户(登录端点"已绑定 → 直接发 token"分支)。
*
* @return 已绑定时返回用户明细(含 currentRole);未绑定返回 null
*/
public BoaUser findBoundUser(String channel, String openid) {
return relRepository.findByChannelAndOpenid(channel, openid)
.map(rel -> userCenterService.getUserInfoWithDeptById(rel.getUserId()))
.orElse(null);
}
/**
* 凭证绑定(loginTicket 身份 + 账号密码)。密码 BCrypt 校验失败统一报"账号或密码错误"
* (不区分账号不存在/密码错,防账号枚举);防爆破计数(LoginGuard)在 SDK 侧。
*/
@Transactional
public BoaUser bindByCredential(String channel, String openid, String unionId,
String account, String password) {
requireIdentity(channel, openid);
if (StringUtils.isBlank(account) || StringUtils.isBlank(password)) {
throw new DException("账号与密码不能为空");
}
SysUser user = sysUserRepository.findByAccount(account);
if (user == null || !passwordEncoder.matches(password, user.getPassword())) {
throw new DException("账号或密码错误");
}
return doBind(channel, openid, unionId, user);
}
/**
* 手机号证明绑定(loginTicket 身份 + 渠道验签器已验证的手机号,小程序一键手机号)。
* 手机号归属由渠道验签器证明(phoneCode → phone),无需密码。
*/
@Transactional
public BoaUser bindByPhone(String channel, String openid, String unionId, String phone) {
requireIdentity(channel, openid);
if (StringUtils.isBlank(phone)) {
throw new DException("手机号不能为空");
}
SysUser user = sysUserRepository.findByPhone(phone);
if (user == null) {
throw new DException("未找到该手机号对应的账号");
}
return doBind(channel, openid, unionId, user);
}
/** 解绑:按登录账号 + 渠道删绑定行(服务端据 token 内 account 定位,非客户端自报 openid) */
@Transactional
public void unbind(String channel, String account) {
if (StringUtils.isBlank(channel) || StringUtils.isBlank(account)) {
throw new DException("渠道与账号不能为空");
}
SysUser user = sysUserRepository.findByAccount(account);
if (user == null) {
return;
}
relRepository.deleteByUserIdAndChannel(user.getId(), channel);
}
private void requireIdentity(String channel, String openid) {
if (StringUtils.isBlank(channel) || StringUtils.isBlank(openid)) {
throw new DException("渠道与 openid 不能为空");
}
}
private BoaUser doBind(String channel, String openid, String unionId, SysUser user) {
if (StatusEnum.DISABLED.equals(user.getDisabled())) {
throw new DException("账号已禁用,请联系管理员");
}
// ① openid 已绑定其他账号 → 拒绝(正常流程登录端点会先查绑定直接发 token,此处兜底并发窗口)
Optional<SysUserChannelRel> byOpenid = relRepository.findByChannelAndOpenid(channel, openid);
if (byOpenid.isPresent() && !byOpenid.get().getUserId().equals(user.getId())) {
throw new DException("该微信已绑定其他账号");
}
// ② 账号在此渠道已绑定其他 openid → 拒绝(换绑须先解绑)
Optional<SysUserChannelRel> byUser = relRepository.findByUserIdAndChannel(user.getId(), channel);
if (byUser.isPresent() && !byUser.get().getOpenid().equals(openid)) {
throw new DException("该账号已绑定其他微信,请先解绑");
}
// ③ unionid 应用层查重:防同一 unionid 绑到两个账号(uk_user_channel 只约束半边)
if (StringUtils.isNotBlank(unionId)) {
for (SysUserChannelRel rel : relRepository.findByUnionId(unionId)) {
if (!rel.getUserId().equals(user.getId())) {
throw new DException("该微信已绑定其他账号");
}
}
}
SysUserChannelRel rel = byOpenid.orElseGet(SysUserChannelRel::new);
rel.setUserId(user.getId());
rel.setChannel(channel);
rel.setOpenid(openid);
rel.setUnionId(StringUtils.isBlank(unionId) ? null : unionId);
rel.setBoundAt(LocalDateTime.now());
relRepository.save(rel);
return userCenterService.getUserInfoWithDeptById(user.getId());
}
}
@@ -0,0 +1,479 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.repository.SysGroupAppRelRepository;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Deque;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录契约实现(UB-2)。<br>
* 授权链路照 {@code /oapi/group} 现行做法:签名由过滤器强制,本层只做「应用 × 学校」可见性——
* 权威事实是 {@code sys_group_app_rel}(该应用在该学校已开通),与
* {@code SchoolAppOverrideService#editAppForSchool} 同一口径,不新建 ACL 表。<br>
* 直查不加缓存(任务书 §0.2 铁律);{@code AccessScopeService} 只服务管理端,本类不引用。
*/
@Service
public class DepartmentDirectoryService implements IDepartmentDirectoryService {
private static final int NOT_DELETED = 0;
/** sys_group.status:0=启用 / 1=停用(方向与 CUSTOM 的 enabled 相反)。 */
private static final int GROUP_STATUS_DISABLED = 1;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private UbCustomDepartmentMemberRepository memberRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private SysGroupAppRelRepository sysGroupAppRelRepository;
// ===================== 目录 =====================
@Override
public List<DepartmentEntryVo> list(Integer appId, Integer schoolId, DepartmentTypeEnum type,
Integer categoryId, boolean withChildren) {
assertAppCanReadSchool(appId, schoolId);
List<DepartmentEntryVo> result = new ArrayList<>();
if (type == null || type == DepartmentTypeEnum.SYSTEM) {
if (categoryId != null) {
throw new DException("categoryId 仅对 CUSTOM 生效");
}
result.addAll(systemDepartments(schoolId, withChildren, true));
}
if (type == null || type == DepartmentTypeEnum.CUSTOM) {
result.addAll(customDepartments(schoolId, categoryId, true));
}
return result;
}
@Override
public List<CustomDeptCategoryVo> categories(Integer appId, Integer schoolId) {
assertAppCanReadSchool(appId, schoolId);
List<CustomDeptCategoryVo> result = new ArrayList<>();
for (UbCustomDeptCategory category :
categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED)) {
CustomDeptCategoryVo vo = new CustomDeptCategoryVo();
vo.setId(category.getId());
vo.setSchoolId(category.getSchoolId());
vo.setName(category.getName());
vo.setSortNo(category.getSortNo());
vo.setDepartmentCount(
departmentRepository.countByCategoryIdAndIsDeleted(category.getId(), NOT_DELETED));
result.add(vo);
}
return result;
}
// ===================== 历史解析 =====================
@Override
public List<DepartmentEntryVo> resolveBatch(Integer appId, List<DepartmentRefRequest> refs) {
if (refs == null || refs.isEmpty()) {
return Collections.emptyList();
}
// 逐校校验授权:一批里出现未开通的学校 → 整批拒绝(不做「能解析多少给多少」)
Set<Integer> schools = new LinkedHashSet<>();
for (DepartmentRefRequest ref : refs) {
if (ref == null || ref.getSchoolId() == null) {
throw new DException("引用项缺少 schoolId");
}
schools.add(ref.getSchoolId());
}
for (Integer schoolId : schools) {
assertAppCanReadSchool(appId, schoolId);
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (DepartmentRefRequest ref : refs) {
result.add(resolveOne(ref));
}
return result;
}
private DepartmentEntryVo resolveOne(DepartmentRefRequest ref) {
DepartmentTypeEnum type = DepartmentTypeEnum.of(ref.getDepartmentType());
if (type == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
if (type == DepartmentTypeEnum.CUSTOM) {
return resolveCustom(ref.getSchoolId(), ref.getDepartmentId());
}
return resolveSystem(ref.getSchoolId(), ref.getDepartmentId());
}
/** CUSTOM 历史解析:软删/停用照常返回名称与状态,仅 selectable=false。 */
private DepartmentEntryVo resolveCustom(Integer schoolId, Integer departmentId) {
UbCustomDepartment department = departmentRepository.findById(departmentId).orElse(null);
if (department == null || !schoolId.equals(department.getSchoolId())) {
return unresolvable(schoolId, DepartmentTypeEnum.CUSTOM, departmentId);
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(department.getSchoolId());
entry.setDepartmentType(DepartmentTypeEnum.CUSTOM.getKey());
entry.setDepartmentId(department.getId());
entry.setName(department.getName());
entry.setCategoryId(department.getCategoryId());
entry.setCategoryName(categoryRepository.findById(department.getCategoryId())
.map(UbCustomDeptCategory::getName).orElse(null));
entry.setDeleted(department.isDeletedRow());
entry.setEnabled(department.isEnabledRow());
entry.setSelectable(!department.isDeletedRow() && department.isEnabledRow());
entry.setMemberCount(memberRepository.countByDepartmentId(department.getId()));
return entry;
}
/** SYSTEM 历史解析:组不存在、或不属于引用所称学校 → 视为解析不到(不把别校同名组当历史值)。 */
private DepartmentEntryVo resolveSystem(Integer schoolId, Integer groupId) {
SysGroup group = sysGroupRepository.findById(groupId).orElse(null);
if (group == null || !schoolId.equals(schoolOfGroup(group))) {
return unresolvable(schoolId, DepartmentTypeEnum.SYSTEM, groupId);
}
boolean enabled = isSystemEnabled(group);
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(DepartmentTypeEnum.SYSTEM.getKey());
entry.setDepartmentId(group.getId());
entry.setName(group.getName());
entry.setParentId(group.getParentId());
entry.setDeleted(false);
entry.setEnabled(enabled);
entry.setSelectable(enabled);
entry.setMemberCount(sysUserGroupRelRepository.countByGroupId(group.getId()));
return entry;
}
private DepartmentEntryVo unresolvable(Integer schoolId, DepartmentTypeEnum type, Integer departmentId) {
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(type.getKey());
entry.setDepartmentId(departmentId);
entry.setDeleted(true);
entry.setEnabled(null);
entry.setSelectable(false);
entry.setMemberCount(0L);
return entry;
}
// ===================== 成员解析 =====================
@Override
public List<BoaUser> members(Integer appId, DepartmentTypeEnum type, Integer departmentId,
boolean forNewAssignment) {
if (type == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
if (departmentId == null) {
throw new DException("部门ID不能为空");
}
if (type == DepartmentTypeEnum.CUSTOM) {
UbCustomDepartment department = departmentRepository.findById(departmentId).orElse(null);
if (department == null) {
throw new DException("自定义部门不存在");
}
assertAppCanReadSchool(appId, department.getSchoolId());
if (forNewAssignment && department.isDeletedRow()) {
throw new DException("部门已删除,不可用于新的指派");
}
if (forNewAssignment && !department.isEnabledRow()) {
throw new DException("部门已停用,不可用于新的指派");
}
return customMembers(departmentId);
}
SysGroup group = sysGroupRepository.findById(departmentId).orElse(null);
if (group == null) {
throw new DException("系统组不存在");
}
Integer schoolId = schoolOfGroup(group);
if (schoolId == null) {
throw new DException("该组织节点不属于任何学校");
}
assertAppCanReadSchool(appId, schoolId);
if (forNewAssignment && !isSystemEnabled(group)) {
throw new DException("部门已停用,不可用于新的指派");
}
return systemMembers(departmentId);
}
private List<BoaUser> customMembers(Integer departmentId) {
List<UbCustomDepartmentMember> rows = memberRepository.findByDepartmentId(departmentId);
if (rows.isEmpty()) {
return Collections.emptyList();
}
List<Integer> userIds = new ArrayList<>();
for (UbCustomDepartmentMember row : rows) {
userIds.add(row.getUserId());
}
return toBoaUsers(userIds);
}
private List<BoaUser> systemMembers(Integer groupId) {
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByGroupId(groupId);
if (rels.isEmpty()) {
return Collections.emptyList();
}
Set<Integer> userIds = new LinkedHashSet<>();
for (SysUserGroupRel rel : rels) {
userIds.add(rel.getUserId());
}
return toBoaUsers(new ArrayList<>(userIds));
}
/**
* 出参形状与 {@code /oapi/group/users} 保持一致:只给 id/账号/姓名/type/status,敏感列不外泄(§4.2 末条)。<br>
* <b>不禁用账号</b>:现行组用户接口就是「照发 + status 标 0」由消费方判({@code toLightBoaUser}),
* 这里若私自过滤,历史解析会静默少人。「人已离校」的处置策略归 flow-center 现行规则(§8.7),UserBase 不猜。<br>
* 只有关系行指向的物理不存在账号(孤儿行)跳过。
*/
private List<BoaUser> toBoaUsers(List<Integer> userIds) {
Map<Integer, SysUser> byId = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
byId.put(user.getId(), user);
}
List<BoaUser> result = new ArrayList<>();
for (Integer userId : userIds) {
SysUser user = byId.get(userId);
if (user == null) {
continue;
}
BoaUser boa = new BoaUser();
boa.setId(user.getId());
boa.setAccount(user.getAccount());
boa.setName(user.getRealName());
boa.setType(user.getType() != null ? user.getType().getKey() : null);
// 状态语义与组用户接口逐字对齐:disabled(NORMAL=0/ DISABLED=1) → BoaUser(1 正常 / 0 禁用)
boa.setStatus(user.getDisabled() == StatusEnum.NORMAL ? 1 : 0);
result.add(boa);
}
return result;
}
// ===================== SYSTEM 目录装配 =====================
/**
* SYSTEM 部门口径与部门管理页一致:<b>教师组(type=7)的直接子节点</b>;
* 学生组(type=8)下的节点不算部门。<br>
* withChildren=true 时连同部门下级一起给出(扁平 + parentId,由消费方重建层级)。<br>
* 停用的部门只在历史解析里出现(本方法由 {@code onlySelectable=true} 调用时会整枝剪掉)。
*/
private List<DepartmentEntryVo> systemDepartments(Integer schoolId, boolean withChildren, boolean onlySelectable) {
List<SysGroup> all = sysGroupRepository.findAll();
Map<Integer, List<SysGroup>> childrenMap = new HashMap<>();
for (SysGroup group : all) {
Integer parent = group.getParentId() == null ? 0 : group.getParentId();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(group);
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (SysGroup child : childrenMap.getOrDefault(schoolId, Collections.<SysGroup>emptyList())) {
if (child.getType() != GroupTypeEnum.TEACHER_GROUP) {
continue;
}
for (SysGroup department : childrenMap.getOrDefault(child.getId(), Collections.<SysGroup>emptyList())) {
boolean appended = appendSystemEntry(result, schoolId, department, onlySelectable);
if (withChildren && appended) {
appendSystemDescendants(result, schoolId, department.getId(), childrenMap, onlySelectable);
}
}
}
return result;
}
private void appendSystemDescendants(List<DepartmentEntryVo> result, Integer schoolId, Integer parentId,
Map<Integer, List<SysGroup>> childrenMap, boolean onlySelectable) {
Deque<Integer> frontier = new ArrayDeque<>();
frontier.add(parentId);
Set<Integer> visited = new HashSet<>();
while (!frontier.isEmpty()) {
Integer cursor = frontier.poll();
for (SysGroup child : childrenMap.getOrDefault(cursor, Collections.<SysGroup>emptyList())) {
if (!visited.add(child.getId())) {
continue;
}
appendSystemEntry(result, schoolId, child, onlySelectable);
frontier.add(child.getId());
}
}
}
/**
* 追加一个 SYSTEM 节点为目录条目。<br>
* 学生组本身永不作为部门;停用节点在 {@code onlySelectable} 下直接跳过。<br>
* 下级节点的组类型(普通组/年级组…)就是组树语义,不做二次筛选,照 parentId 原样下发。
*
* @return 该节点是否进入结果(false 表示被过滤,其下级不再展开)
*/
private boolean appendSystemEntry(List<DepartmentEntryVo> result, Integer schoolId, SysGroup group,
boolean onlySelectable) {
if (group.getType() == GroupTypeEnum.STUDENT_GROUP) {
return false;
}
boolean enabled = isSystemEnabled(group);
if (onlySelectable && !enabled) {
return false;
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(schoolId);
entry.setDepartmentType(DepartmentTypeEnum.SYSTEM.getKey());
entry.setDepartmentId(group.getId());
entry.setName(group.getName());
entry.setParentId(group.getParentId());
entry.setEnabled(enabled);
entry.setDeleted(false);
entry.setSelectable(enabled);
entry.setMemberCount(sysUserGroupRelRepository.countByGroupId(group.getId()));
result.add(entry);
return true;
}
/** sys_group.status:0=启用(含存量 null)/ 1=停用。方向与 CUSTOM 的 enabled 相反。 */
private boolean isSystemEnabled(SysGroup group) {
return group.getStatus() == null || group.getStatus() != GROUP_STATUS_DISABLED;
}
// ===================== CUSTOM 目录装配 =====================
private List<DepartmentEntryVo> customDepartments(Integer schoolId, Integer categoryId, boolean onlySelectable) {
Map<Integer, String> categoryNames = new LinkedHashMap<>();
for (UbCustomDeptCategory category :
categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED)) {
if (categoryId != null && !categoryId.equals(category.getId())) {
continue;
}
categoryNames.put(category.getId(), category.getName());
}
if (categoryNames.isEmpty()) {
if (categoryId != null) {
throw new DException("分类不存在或不属于该校");
}
return Collections.emptyList();
}
List<DepartmentEntryVo> result = new ArrayList<>();
for (Integer liveCategoryId : categoryNames.keySet()) {
List<UbCustomDepartment> rows =
departmentRepository.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(liveCategoryId, NOT_DELETED);
Map<Integer, Long> memberCounts = countMembers(rows);
for (UbCustomDepartment row : rows) {
boolean enabled = row.isEnabledRow();
if (onlySelectable && !enabled) {
continue;
}
DepartmentEntryVo entry = new DepartmentEntryVo();
entry.setSchoolId(row.getSchoolId());
entry.setDepartmentType(DepartmentTypeEnum.CUSTOM.getKey());
entry.setDepartmentId(row.getId());
entry.setName(row.getName());
entry.setCategoryId(row.getCategoryId());
entry.setCategoryName(categoryNames.get(row.getCategoryId()));
entry.setEnabled(enabled);
entry.setDeleted(false);
entry.setSelectable(enabled);
entry.setMemberCount(memberCounts.getOrDefault(row.getId(), 0L));
result.add(entry);
}
}
return result;
}
private Map<Integer, Long> countMembers(List<UbCustomDepartment> rows) {
List<Integer> ids = new ArrayList<>();
for (UbCustomDepartment row : rows) {
ids.add(row.getId());
}
Map<Integer, Long> counts = new HashMap<>();
if (ids.isEmpty()) {
return counts;
}
for (UbCustomDepartmentMember member : memberRepository.findByDepartmentIdIn(ids)) {
counts.merge(member.getDepartmentId(), 1L, Long::sum);
}
return counts;
}
// ===================== 授权与锚点 =====================
/**
* 应用 × 学校可见性:签名过滤器只证明「是哪个应用」,能不能读这所学校由开通关系决定(A15)。
*/
private void assertAppCanReadSchool(Integer appId, Integer schoolId) {
if (appId == null) {
throw new DException("无法识别调用方应用,禁止读取部门目录");
}
if (schoolId == null) {
throw new DException("schoolId 不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId).orElse(null);
if (school == null || school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校不存在或不是学校组");
}
if (!sysGroupAppRelRepository.existsByGroupIdAndAppId(schoolId, appId)) {
throw new DException("该应用未在本校开通,无权读取该校部门目录");
}
}
/** 沿 parentId 上溯最近的学校组(CUSTOM 无此需求,只给 SYSTEM 组用);带 visited 防环。 */
private Integer schoolOfGroup(SysGroup group) {
Map<Integer, SysGroup> byId = new HashMap<>();
for (SysGroup each : sysGroupRepository.findAll()) {
byId.put(each.getId(), each);
}
Set<Integer> visited = new HashSet<>();
SysGroup cursor = group;
while (cursor != null && visited.add(cursor.getId())) {
if (cursor.getType() == GroupTypeEnum.SCHOOL_GROUP) {
return cursor.getId();
}
Integer parentId = cursor.getParentId();
if (parentId == null || parentId == 0) {
return null;
}
cursor = byId.get(parentId);
}
return null;
}
}
@@ -2,6 +2,8 @@ package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaModule;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.constant.AppLevelModuleId;
import com.yunhai.userbase.entity.*; import com.yunhai.userbase.entity.*;
import com.yunhai.userbase.enums.GroupTypeEnum; import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.platform.utils.enums.IEnum; import com.yunhai.platform.utils.enums.IEnum;
@@ -127,6 +129,10 @@ public class GroupAuthzService implements IGroupAuthzService {
@Autowired @Autowired
private ISysGroupService sysGroupService; private ISysGroupService sysGroupService;
/** 学校单点开通闸门的生效锚点解析(当前激活身份校兜底,与 AppCategoryService.resolveEffectiveSchool 同口径)。 */
@Autowired
private IdentityService identityService;
@Autowired @Autowired
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
@@ -256,8 +262,14 @@ public class GroupAuthzService implements IGroupAuthzService {
GroupAuthzAppVo appVo = new GroupAuthzAppVo(); GroupAuthzAppVo appVo = new GroupAuthzAppVo();
BeanUtils.copyProperties(app, appVo); BeanUtils.copyProperties(app, appVo);
List<AppModule> modules = appModuleMap.get(app.getId()); List<AppModule> modules = appModuleMap.get(app.getId());
if (modules != null) { if (modules != null && !modules.isEmpty()) {
appVo.setModules(buildModuleTree(modules, permMap)); appVo.setModules(buildModuleTree(modules, permMap));
} else {
// 无子模块应用(如「一周安排」):授权挂在 -appId 的整应用哨兵记录上(见 AppLevelModuleId),
// 有记录即视为已授权,inherit_flag 同步回显,否则该应用勾选态永远无回显。
SysGroupModulePerm appPerm = permMap.get(AppLevelModuleId.of(app.getId()));
appVo.setChecked(appPerm != null);
appVo.setInheritFlag(appPerm != null && Boolean.TRUE.equals(appPerm.getInheritFlag()));
} }
result.add(appVo); result.add(appVo);
} }
@@ -515,6 +527,8 @@ public class GroupAuthzService implements IGroupAuthzService {
/** /**
* 解析某条授权项实际要操作的模块ID集合。<br> * 解析某条授权项实际要操作的模块ID集合。<br>
* - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。<br> * - moduleId 为 null 或 0:返回该应用下全部真实模块的 ID(「整应用」语义)。<br>
* - 应用下没有任何模块时:返回 {@link AppLevelModuleId#of(Integer)}(-appId)——
* 把「整应用」授权落到哨兵记录上,否则这类应用永远写不进授予、也永远回显为空。<br>
* - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。<br> * - 具体 moduleId:校验其确实存在且属于该应用,越权写别的应用的模块会被拒绝。<br>
*/ */
private List<Integer> resolveModuleIds(Integer appId, Integer moduleId) { private List<Integer> resolveModuleIds(Integer appId, Integer moduleId) {
@@ -523,7 +537,7 @@ public class GroupAuthzService implements IGroupAuthzService {
for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) { for (AppModule m : appModuleRepository.findByAppIdIn(Collections.singletonList(appId))) {
ids.add(m.getId()); ids.add(m.getId());
} }
return ids; return ids.isEmpty() ? Collections.singletonList(AppLevelModuleId.of(appId)) : ids;
} }
AppModule m = appModuleRepository.findById(moduleId).orElse(null); AppModule m = appModuleRepository.findById(moduleId).orElse(null);
if (m == null) { if (m == null) {
@@ -558,6 +572,12 @@ public class GroupAuthzService implements IGroupAuthzService {
List<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId)); List<AppModule> allModules = appModuleRepository.findByAppIdIn(Collections.singletonList(appId));
return assembleModuleTree(allModules); return assembleModuleTree(allModules);
} }
// 2.5 学校单点开通硬闸门(2026-10-12):生效锚点(显式 schId,或按「当前身份校→主归属校」兜底)
// 未在自身 sys_group_app_rel 开通该应用 → 直接空,角色授权与祖先组开通均不得绕过。
// 区域/根锚点按该锚点自身单点判定(不沿祖先);解析不到锚点=无学校语境=无应用。
if (!isAppOpenedAtEffectiveSchool(appId, schId, userId)) {
return Collections.emptyList();
}
// 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug): // 3. 归属组集合解析(与权限一览 findUserPermissionTree 语义对齐,修复"只返回学校节点"的 bug):
// - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身), // - schId 非空:只取用户真实归属组中「位于该学校子树内」的组(含直接挂学校组本身),
// 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块, // 每个组独立建链向上判定;子树内无任何归属组 → 保留空 selfGroupIds(方案A:仅靠角色授权拿模块,
@@ -653,6 +673,41 @@ public class GroupAuthzService implements IGroupAuthzService {
return assembleModuleTreeById(permittedModuleIds, buildAppModuleMap(appModules)); return assembleModuleTreeById(permittedModuleIds, buildAppModuleMap(appModules));
} }
/**
* 学校单点开通硬闸门判定:应用是否落在「生效锚点」自身的开通集合内。<br>
* 闸门查询走 {@link #findAuthorizedAppIds(Integer)}(本组单点、不沿祖先、不并角色),
* 区域/根锚点同样按该锚点自身判定;显式 schId 先过 {@code assertSchoolId} 防误传非法 id。
*/
private boolean isAppOpenedAtEffectiveSchool(Integer appId, Integer schId, Integer userId) {
Integer anchor = resolveEffectiveSchoolAnchor(schId, userId);
if (anchor == null) {
return false;
}
return findAuthorizedAppIds(anchor).contains(appId);
}
/**
* 生效学校锚点解析(与 {@code AppCategoryService.resolveEffectiveSchool} 同口径的只读版):<br>
* ① 显式 schId → assertSchoolId 锚点校验后直接使用(不校验用户归属,越权收窄由闸门自身完成);<br>
* ② null → 当前激活身份学校(sys_user_current_role);<br>
* ③ 仍无 → 主归属组向上最近学校/区域/根锚点;均无(平台账号无组关系)→ null(无学校语境)。
*/
private Integer resolveEffectiveSchoolAnchor(Integer schId, Integer userId) {
if (schId != null) {
sysGroupService.assertSchoolId(schId);
return schId;
}
BoaRoleInfo current = identityService.getCurrent(userId);
if (current != null && current.getSchId() != null) {
return current.getSchId();
}
SysUserGroupRel main = mainGroupResolver.resolve(userId).orElse(null);
if (main == null || main.getGroupId() == null) {
return null;
}
return sysGroupService.resolveSchoolId(main.getGroupId());
}
/** /**
* 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。<br> * 收集用户启用角色下=且属于目标应用 appId 的模块ID集合(角色授权并集专用的"应用过滤版")。<br>
* 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块; * 与 {@link #collectEnabledRoleModuleIds(Integer)} 的区别:后者返回全部角色模块;
@@ -1847,6 +1902,11 @@ public class GroupAuthzService implements IGroupAuthzService {
siblings.sort(siblingOrder); siblings.sort(siblingOrder);
} }
List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList()); List<AppModule> roots = childrenMap.getOrDefault(0, Collections.emptyList());
if (roots.isEmpty() && !modules.isEmpty()) {
// 脏数据兜底:parent_id 指向已删除模块时根节点全丢,会把「有模块的应用」误判成无模块应用
// (前端表现为该应用没有 + 号、勾选态恒为空),此时把所有模块提升为根节点展示
roots = modules;
}
List<GroupAuthzModuleVo> tree = new ArrayList<>(); List<GroupAuthzModuleVo> tree = new ArrayList<>();
for (AppModule root : roots) { for (AppModule root : roots) {
tree.add(buildModuleNode(root, childrenMap, permMap)); tree.add(buildModuleNode(root, childrenMap, permMap));
@@ -1986,4 +2046,24 @@ public class GroupAuthzService implements IGroupAuthzService {
} }
return new ArrayList<>(appIds); return new ArrayList<>(appIds);
} }
@Override
public List<Integer> findUserAuthorizedAppIds(Integer userId, Integer schoolId) {
Objects.requireNonNull(userId, "用户ID不能为空");
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
throw new DException("用户不存在");
}
// 超级管理员:保留 bypass(D1 默认,基石账号平台级不绑定单一学校),口径与无学校版一致
if (isSuperAdmin(user.getType(), userId)) {
return findUserAuthorizedAppIds(userId);
}
// 学校单点开通口径(2026-10-12 硬闸门):生效锚点自身开通的应用集合,
// 不沿祖先链、不并入角色模块所属应用(角色授权不再补充「学校未开通」的应用)。
Integer anchor = resolveEffectiveSchoolAnchor(schoolId, userId);
if (anchor == null) {
return Collections.emptyList();
}
return findAuthorizedAppIds(anchor);
}
} }
@@ -76,17 +76,12 @@ public class IdentityService {
/** /**
* 用户全部可选身份(正常态),用于「切换身份」页渲染。 * 用户全部可选身份(正常态),用于「切换身份」页渲染。
* 排序:主身份优先 → sortNo 升序 → id 升序(「未切换默认第一个」的确定性保证)。 * 排序见 {@link #sortForDisplay}:主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序。
*/ */
public List<BoaRoleInfo> listAvailable(Integer userId) { public List<BoaRoleInfo> listAvailable(Integer userId) {
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
SysUserRoleRel main = resolveMainIdentity(userId); SysUserRoleRel main = resolveMainIdentity(userId);
List<SysUserRoleRel> sorted = rels.stream() List<SysUserRoleRel> sorted = sortForDisplay(rels, main);
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
main != null && Objects.equals(r.getId(), main.getId()) ? 0 : 1)
.thenComparingInt(SysUserRoleRel::getSortNo)
.thenComparingInt(SysUserRoleRel::getId))
.collect(Collectors.toList());
List<BoaRoleInfo> result = new ArrayList<>(sorted.size()); List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
for (SysUserRoleRel rel : sorted) { for (SysUserRoleRel rel : sorted) {
result.add(toBoaRoleInfo(rel.getRoleId(), rel.getSchoolId())); result.add(toBoaRoleInfo(rel.getRoleId(), rel.getSchoolId()));
@@ -94,6 +89,27 @@ public class IdentityService {
return result; return result;
} }
/**
* 身份列表展示排序({@link #listAvailable} 与 {@code SysUserService#findUserRoles} 共用,保证两接口同序):<br>
* ① 主身份所在学校整体置顶(主身份是该校教师或管理员均成立);<br>
* ② 其余学校按 schoolId 升序;<br>
* ③ 校内按 roleId 升序(老师1 → 校领导2 → 管理员7);<br>
* ④ 兜底 id 升序。<br>
* 主身份行<b>不再单独置顶</b>:主校内部一律按 roleId,故主身份为管理员时该校教师仍排其前。
* 「未切换默认激活身份」由 {@link #getCurrent} 独立解析,不依赖本列表首元素。
*/
public static List<SysUserRoleRel> sortForDisplay(List<SysUserRoleRel> rels, SysUserRoleRel main) {
Integer mainSchoolId = main != null ? main.getSchoolId() : null;
return rels.stream()
.sorted(Comparator
.comparingInt((SysUserRoleRel r) ->
Objects.equals(r.getSchoolId(), mainSchoolId) ? 0 : 1)
.thenComparingInt(SysUserRoleRel::getSchoolId)
.thenComparingInt(SysUserRoleRel::getRoleId)
.thenComparingInt(SysUserRoleRel::getId))
.collect(Collectors.toList());
}
/** /**
* 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。 * 当前激活身份:优先读 sys_user_current_role;无切换记录时兜底主身份(动态解析,替代 is_main 物化字段)。
* 连主身份都没有时返回 null(前端引导创建身份)。<br><br> * 连主身份都没有时返回 null(前端引导创建身份)。<br><br>
@@ -194,6 +194,15 @@ public class SchoolAppOverrideService {
return vo; return vo;
} }
/**
* 「全部」视图(未选应用类型)排序:按 id 正序 —— 创建顺序,最新创建的在最后。
* 与 SUPER 分支同口径(AppSettingsController:「全部」= Sort.by(ASC,"id")):
* sort_no 是「类型内」序号,跨类型打平无全局意义,故「全部」不参与生效排序。
*/
private Comparator<ApplicationInfoVo> appIdOrder() {
return Comparator.comparing(ApplicationInfoVo::getId, Comparator.nullsLast(Integer::compareTo));
}
/** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */ /** 生效排序:override sortNo 优先,空沿用主表(空视为999),并列按 id 升序 */
private Comparator<ApplicationInfoVo> effectiveAppOrder(Map<Integer, SysSchoolAppOverride> overrides) { private Comparator<ApplicationInfoVo> effectiveAppOrder(Map<Integer, SysSchoolAppOverride> overrides) {
return (a, b) -> { return (a, b) -> {
@@ -243,9 +252,12 @@ public class SchoolAppOverrideService {
filtered.add(app); filtered.add(app);
} }
Map<Integer, SysSchoolAppOverride> overrides = appOverridesFor(schools); Map<Integer, SysSchoolAppOverride> overrides = appOverridesFor(schools);
// 「全部」视图(未选应用类型)按 id 正序——与 SUPER 分支同口径(AppSettingsController:
// 全部 = Sort.by(ASC,"id"))。sort_no 是「类型内」序号,跨类型打平无全局意义,
// 故「全部」不参与生效排序;选中类型仍走 override/主表生效排序(拖拽结果可见)。
List<ApplicationInfoVo> vos = filtered.stream() List<ApplicationInfoVo> vos = filtered.stream()
.map(app -> mergeAppVo(app, overrides)) .map(app -> mergeAppVo(app, overrides))
.sorted(effectiveAppOrder(overrides)) .sorted(categoryId == null ? appIdOrder() : effectiveAppOrder(overrides))
.collect(Collectors.toList()); .collect(Collectors.toList());
return pageInMemory(vos, pageable); return pageInMemory(vos, pageable);
} }
@@ -63,6 +63,13 @@ public class SysGroupService implements ISysGroupService {
/** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */ /** 开放接口/oapi 写操作无登录态时使用的审计人哨兵值(非真实用户ID),用于兜底 create_user/update_user 的 NOT NULL 约束。 */
private static final int SYSTEM_USER_ID = 0; private static final int SYSTEM_USER_ID = 0;
/**
* 部门表格「人员」列的姓名预览上限。<br>
* 表格最多显示 3 个名字 + 计数,tooltip 展示这份预览;部门上百人时不把这些名字塞进 getTree 响应,
* 完整名单由前端点「查看全部」走 /api/sysuser/List?groupId=xxx 分页取。
*/
static final int MEMBER_PREVIEW_LIMIT = 50;
/** /**
* 层级类型约束:父类型 -> 允许直接创建的子类型集合。 * 层级类型约束:父类型 -> 允许直接创建的子类型集合。
* key 为 {@code null} 表示「根节点(教育云中心)」。 * key 为 {@code null} 表示「根节点(教育云中心)」。
@@ -228,6 +235,51 @@ public class SysGroupService implements ISysGroupService {
deleteInternal(id); deleteInternal(id);
} }
/**
* 启停状态翻转(2026-10-20 部门线 P0 最小批):严格单列 UPDATE。<br>
* ① 只走 {@link SysGroupRepository#updateStatus}(@Modifying JPQL,不触发实体监听器/审计列);
* ② 不判组类型、不动删除行为、不碰任何其他表;
* ③ 组域写口义务:发 {@link GroupChangedEvent} 驱逐 groupSnapshot 缓存——
* oapi 组树(tree/byId/list)与 GetTree 都吃快照,不发则透出的 status 最多滞后 30min;
* 不发 DataSyncEvent(推送通道 push_enabled 默认关,status 也无推送消费方)。
*/
@Override
@Transactional
public void changeStatus(Integer id, boolean disabled) {
if (id == null || sysGroupRepository.findById(id).orElse(null) == null) {
throw new DException("系统组不存在");
}
int updated = sysGroupRepository.updateStatus(id, disabled ? 1 : 0);
if (updated == 0) {
throw new DException("系统组不存在");
}
eventPublisher.publishEvent(new GroupChangedEvent(this, "changeStatus"));
}
/**
* 「包含子组」翻转(2026-10-21 部门线):严格单列 UPDATE,范式与 {@link #changeStatus} 完全一致。<br>
* 与 changeStatus 的唯一差别:本属性只在**学校组**节点上有意义(对齐 SysGroup 字段注释「仅学校节点使用」),
* 故在此强制校验类型,非学校组直接拒绝——前端只在学校行放开关,服务端不能只靠它。<br>
* 必须发 {@link GroupChangedEvent}:GetTree / oapi 组树都吃 groupSnapshot 缓存,
* 不发则页面读到的 includeChildren 最多滞后 30min。
*/
@Override
@Transactional
public void changeIncludeChildren(Integer id, boolean include) {
SysGroup group = (id == null) ? null : sysGroupRepository.findById(id).orElse(null);
if (group == null) {
throw new DException("系统组不存在");
}
if (group.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("包含子组仅可设置在学校组节点");
}
int updated = sysGroupRepository.updateIncludeChildren(id, include ? 1 : 0);
if (updated == 0) {
throw new DException("系统组不存在");
}
eventPublisher.publishEvent(new GroupChangedEvent(this, "changeIncludeChildren"));
}
@Override @Override
@Transactional @Transactional
public void delete(Integer id, Integer schoolId) { public void delete(Integer id, Integer schoolId) {
@@ -318,7 +370,9 @@ public class SysGroupService implements ISysGroupService {
Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap(); Map<Integer, List<SysGroup>> childrenMap = snapshot.getChildrenMap();
// 从根(pid=0)开始组装树 // 从根(pid=0)开始组装树
List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>()); List<SysGroup> roots = childrenMap.getOrDefault(ROOT_PARENT_ID, new ArrayList<>());
return roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList()); List<SysGroupVo> tree = roots.stream().map(r -> buildNode(r, childrenMap)).collect(Collectors.toList());
fillMemberStats(tree);
return tree;
} }
@Override @Override
@@ -330,7 +384,83 @@ public class SysGroupService implements ISysGroupService {
if (scope == null || scope.isSuper()) { if (scope == null || scope.isSuper()) {
return getTree(); return getTree();
} }
return buildTrimmedTree(scope, snapshot.getAll(), false); List<SysGroupVo> tree = buildTrimmedTree(scope, snapshot.getAll(), false);
fillMemberStats(tree);
return tree;
}
/**
* 给树节点补直属成员数与前 N 个姓名预览(部门表格「人员」列)。<br>
* 口径与 oapi 部门目录一致:sys_user_group_rel 按 groupId 计数,不排除转出;
* 整棵树只发两次查询(关联表 in + 用户表 in),不受节点数影响。
*/
private void fillMemberStats(List<SysGroupVo> tree) {
List<Integer> groupIds = new ArrayList<>();
collectGroupIds(tree, groupIds);
if (groupIds.isEmpty()) {
return;
}
List<SysUserGroupRel> rels = sysUserGroupRelRepository.findByGroupIdIn(groupIds);
if (rels == null || rels.isEmpty()) {
return;
}
Map<Integer, List<Integer>> relByGroup = new HashMap<>();
Set<Integer> userIds = new HashSet<>();
for (SysUserGroupRel rel : rels) {
if (rel.getGroupId() == null || rel.getUserId() == null) {
continue;
}
relByGroup.computeIfAbsent(rel.getGroupId(), k -> new ArrayList<>()).add(rel.getUserId());
userIds.add(rel.getUserId());
}
Map<Integer, String> nameById = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
nameById.put(user.getId(),
StringUtils.hasText(user.getRealName()) ? user.getRealName() : user.getAccount());
}
Map<Integer, SysGroupVo> voById = new HashMap<>();
collectVoById(tree, voById);
for (Map.Entry<Integer, List<Integer>> entry : relByGroup.entrySet()) {
SysGroupVo vo = voById.get(entry.getKey());
if (vo == null) {
continue;
}
List<Integer> members = entry.getValue();
vo.setMemberCount((long) members.size());
List<String> preview = new ArrayList<>();
for (Integer uid : members) {
if (preview.size() >= MEMBER_PREVIEW_LIMIT) {
break;
}
String name = nameById.get(uid);
if (name != null) {
preview.add(name);
}
}
vo.setMemberNames(preview);
}
}
private void collectGroupIds(List<SysGroupVo> nodes, List<Integer> out) {
for (SysGroupVo vo : nodes) {
if (vo.getId() != null) {
out.add(vo.getId());
}
if (vo.getChildren() != null) {
collectGroupIds(vo.getChildren(), out);
}
}
}
private void collectVoById(List<SysGroupVo> nodes, Map<Integer, SysGroupVo> out) {
for (SysGroupVo vo : nodes) {
if (vo.getId() != null) {
out.put(vo.getId(), vo);
}
if (vo.getChildren() != null) {
collectVoById(vo.getChildren(), out);
}
}
} }
@Override @Override
@@ -850,14 +980,20 @@ public class SysGroupService implements ISysGroupService {
// ===================== 私有工具 ===================== // ===================== 私有工具 =====================
/** SysGroup -> BoaGroup(id/name/parentId/type/sort);children 默认 null(未展开)。 */ /**
* SysGroup -> BoaGroup(id/name/parentId/type/sort + status);children 默认 null(未展开)。<br>
* 出参载体为 {@link com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo}(P0 起 oapi 组树透出启停状态;
* 返回类型保持 BoaGroup,消费方零改动,JSON 面多一个 status 键)。null 视为 0(存量行/未迁移列)。
*/
private BoaGroup convertToBoaGroup(SysGroup entity) { private BoaGroup convertToBoaGroup(SysGroup entity) {
BoaGroup bg = new BoaGroup(); com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo bg =
new com.yunhai.userbase.model.sysgroup.BoaGroupStatusVo();
bg.setId(entity.getId()); bg.setId(entity.getId());
bg.setName(entity.getName()); bg.setName(entity.getName());
bg.setParentId(entity.getParentId()); bg.setParentId(entity.getParentId());
bg.setType(entity.getType() == null ? null : entity.getType().getKey()); bg.setType(entity.getType() == null ? null : entity.getType().getKey());
bg.setSort(entity.getSort()); bg.setSort(entity.getSort());
bg.setStatus(entity.getStatus() == null ? 0 : entity.getStatus());
return bg; return bg;
} }
@@ -96,6 +96,10 @@ public class SysUserService implements ISysUserService {
private static final Set<String> SUPER_ADMIN_ACCOUNTS = private static final Set<String> SUPER_ADMIN_ACCOUNTS =
new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3")); new HashSet<>(Arrays.asList("yunhaisch", "yunhaisch2", "yunhaisch3"));
/** 邮箱格式(本人联系方式修改用):本地段常规字符 + 点分域名,总长另由请求 @Size 限制 */
private static final java.util.regex.Pattern CONTACT_EMAIL =
java.util.regex.Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\\.[A-Za-z0-9-]+)+$");
@Autowired @Autowired
private SysUserRepository sysUserRepository; private SysUserRepository sysUserRepository;
@@ -289,6 +293,52 @@ public class SysUserService implements ISysUserService {
sysUserRepository.save(entity); sysUserRepository.save(entity);
} }
/**
* 修改本人联系方式(oapi /oapi/user/self/contact):只写 phone/email 两字段,其余不动。<br>
* 空白入参=「不修改该字段」(不清空——清空语义不在本接口范围);手机号是 byPhone 身份维度,
* 改值前全库查重;更新后发 UPDATE_USER 推送 + UserChangedEvent(带旧 phone: key 提示)驱逐
* boaUser 缓存,控制器紧跟着的 getUserInfoWithDeptById 回读即新值(AFTER_COMMIT 后于回读执行)。
*/
@Override
@Transactional
public void updateSelfContact(Integer userId, String phone, String email) {
SysUser entity = sysUserRepository.findById(userId)
.orElseThrow(() -> new DException("系统用户不存在"));
String newPhone = StringUtils.trimToNull(phone);
String newEmail = StringUtils.trimToNull(email);
if (newPhone == null && newEmail == null) {
throw new DException("手机号与邮箱至少填写一项");
}
if (newPhone != null && !ValidatorUtil.isPhone(newPhone)) {
throw new DException("手机号格式不正确");
}
if (newEmail != null && !CONTACT_EMAIL.matcher(newEmail).matches()) {
throw new DException("邮箱格式不正确");
}
if (newPhone != null && !newPhone.equals(entity.getPhone())) {
SysUser samePhone = sysUserRepository.findByPhone(newPhone);
if (samePhone != null && !samePhone.getId().equals(userId)) {
throw new DException("手机号已被其他用户使用");
}
}
// 改手机号后旧 phone: 维度缓存 key 无处回查,须随事件显式驱逐(同 editInternal 口径);
// 仅改邮箱时手机号未变,不加提示(避免无谓多驱逐一个 key)
Set<String> staleKeyHints = new HashSet<>();
if (newPhone != null && StringUtils.isNotBlank(entity.getPhone())
&& !entity.getPhone().equals(newPhone)) {
staleKeyHints.add("phone:" + entity.getPhone());
}
if (newPhone != null) {
entity.setPhone(newPhone);
}
if (newEmail != null) {
entity.setEmail(newEmail);
}
entity.setUpdateUser(SYSTEM_USER_ID);
sysUserRepository.save(entity);
publishUserEvent(PushInterfaceType.UPDATE_USER, userId, staleKeyHints);
}
/** /**
* 开放接口的用户身份写入(追加语义,只加不删):<br> * 开放接口的用户身份写入(追加语义,只加不删):<br>
* 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑); * 学校范围 = 显式 schoolId,缺省按 request.groupId 解析(与新增口同一约定,调用方不感知解析逻辑);
@@ -677,7 +727,8 @@ public class SysUserService implements ISysUserService {
return new HashSet<>(sysGroupService.resolveSchoolIds(groupIds).values()); return new HashSet<>(sysGroupService.resolveSchoolIds(groupIds).values());
} }
/** 身份行展示顺序:主身份优先 → sortNo → id(与 IdentityService.getUserRoles 同序)。 */ /** 管理端列表「用户身份」单值列取首行用的顺序:主身份优先 → sortNo → id。
* 注意:这与面向用户的身份列表 {@link IdentityService#sortForDisplay}(主校置顶 → 校内 roleId)不同,刻意保持。 */
private Comparator<SysUserRoleRel> identityRowOrder(SysUserRoleRel mainRel) { private Comparator<SysUserRoleRel> identityRowOrder(SysUserRoleRel mainRel) {
return Comparator.comparingInt((SysUserRoleRel r) -> return Comparator.comparingInt((SysUserRoleRel r) ->
mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1) mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
@@ -1436,16 +1487,12 @@ public class SysUserService implements ISysUserService {
if (userId == null) { if (userId == null) {
throw new DException("用户ID不能为空"); throw new DException("用户ID不能为空");
} }
// 数据源:sys_user_role_rel(平台身份角色权威源),内存排序 主身份优先 → sortNo → id // 数据源:sys_user_role_rel(平台身份角色权威源),内存排序与 /oapi/identity/available 同序:
// (修正:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug) // 主身份所在学校置顶 → 其他学校 schoolId 升序 → 校内 roleId 升序(见 IdentityService#sortForDisplay)
// (修正历史:此前误读 sys_user_app_role_rel 业务权限角色 + 主归属组当学校,导致「本组」bug)
List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL); List<SysUserRoleRel> rels = sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL);
SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId); SysUserRoleRel mainRel = identityService.resolveMainIdentity(userId);
List<SysUserRoleRel> sorted = rels.stream() List<SysUserRoleRel> sorted = IdentityService.sortForDisplay(rels, mainRel);
.sorted(Comparator.comparingInt((SysUserRoleRel r) ->
mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1)
.thenComparingInt(SysUserRoleRel::getSortNo)
.thenComparingInt(SysUserRoleRel::getId))
.collect(Collectors.toList());
List<BoaRoleInfo> result = new ArrayList<>(sorted.size()); List<BoaRoleInfo> result = new ArrayList<>(sorted.size());
for (SysUserRoleRel rel : sorted) { for (SysUserRoleRel rel : sorted) {
BoaRoleInfo info = new BoaRoleInfo(); BoaRoleInfo info = new BoaRoleInfo();
@@ -1795,6 +1842,24 @@ public class SysUserService implements ISysUserService {
} }
} }
// ===================== 按 id 批量窄读 =====================
/** 批量上限:聚合页一次回显的收件用户规模远小于此,防误用放大查询 */
private static final int LIST_BY_IDS_MAX = 200;
@Override
public List<SysUser> listByIds(List<Integer> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
List<Integer> safeIds = ids.stream()
.filter(id -> id != null && id > 0)
.distinct()
.limit(LIST_BY_IDS_MAX)
.collect(Collectors.toList());
return safeIds.isEmpty() ? Collections.emptyList() : sysUserRepository.findAllById(safeIds);
}
// ===================== 推送事件 + 缓存失效事件 ===================== // ===================== 推送事件 + 缓存失效事件 =====================
// 用户域全部写口在此收口:DataSyncEvent 由 PushDispatcher 消费(push_enabled 默认 0 不触发); // 用户域全部写口在此收口:DataSyncEvent 由 PushDispatcher 消费(push_enabled 默认 0 不触发);
// UserChangedEvent 由 CacheInvalidationListener AFTER_COMMIT 消费,按 id/login/phone // UserChangedEvent 由 CacheInvalidationListener AFTER_COMMIT 消费,按 id/login/phone
@@ -0,0 +1,467 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.SysGroup;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.entity.SysUserGroupRel;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.entity.UbCustomDepartment;
import com.yunhai.userbase.entity.UbCustomDepartmentMember;
import com.yunhai.userbase.enums.GroupTypeEnum;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.repository.SysGroupRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentMemberRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ICustomDepartmentService;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理实现(CUSTOM 侧权威写口)。<br>
* 权限:schoolId 全部由 {@link AccessScopeService} 从登录身份解析,请求体只作核对;
* 每个写口先定位对象、再校验其所属学校落在当前范围内(跨校一律拒绝,A09)。<br>
* 成员范围(D4):本校 + 教职工,排除学生组(type=8)子树下的用户。<br>
* 本期 CUSTOM 目录不加缓存(任务书 §0.2),写口不发驱逐事件;如后续加缓存须全部写口 100% 驱逐。
*/
@Service
public class UbCustomDepartmentService implements ICustomDepartmentService {
private static final int NOT_DELETED = 0;
/** 候选教职工一次最多返回多少条(人多时靠关键字缩小范围)。 */
private static final int CANDIDATE_LIMIT = 200;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentMemberRepository memberRepository;
@Autowired
private SysGroupRepository sysGroupRepository;
@Autowired
private SysUserGroupRelRepository sysUserGroupRelRepository;
@Autowired
private SysUserRepository sysUserRepository;
@Autowired
private ISysGroupService sysGroupService;
@Autowired
private AccessScopeService accessScopeService;
// ===================== 列表 =====================
@Override
public List<CustomDepartmentVo> list(Integer schoolId, Integer categoryId) {
Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId);
UbCustomDeptCategory category = requireLiveCategory(categoryId);
if (!scoped.equals(category.getSchoolId())) {
throw new DException("分类不属于该学校范围");
}
List<UbCustomDepartment> rows = departmentRepository
.findByCategoryIdAndIsDeletedOrderBySortNoAscIdAsc(category.getId(), NOT_DELETED);
if (rows.isEmpty()) {
return Collections.emptyList();
}
Map<Integer, Long> memberCounts = countMembersByDepartment(rows);
List<CustomDepartmentVo> result = new ArrayList<>();
for (UbCustomDepartment row : rows) {
CustomDepartmentVo vo = toVo(row, category.getName());
vo.setMemberCount(memberCounts.getOrDefault(row.getId(), 0L));
result.add(vo);
}
return result;
}
// ===================== 部门增删改 =====================
@Override
@Transactional
public CustomDepartmentVo add(CustomDepartmentRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
UbCustomDeptCategory category = requireLiveCategory(request.getCategoryId());
if (!schoolId.equals(category.getSchoolId())) {
throw new DException("分类不属于该学校范围");
}
String name = requireName(request.getName());
requireNameFree(schoolId, category.getId(), name, null);
UbCustomDepartment entity = new UbCustomDepartment();
entity.setSchoolId(schoolId);
entity.setCategoryId(category.getId());
entity.setName(name);
Integer maxSort = departmentRepository.findMaxSortNoByCategory(category.getId());
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
// 新建默认启用(规格书 §2.2)
entity.setEnabled(1);
entity.setIsDeleted(NOT_DELETED);
entity.setDelMarker(0L);
departmentRepository.save(entity);
return toVo(entity, category.getName());
}
@Override
@Transactional
public CustomDepartmentVo rename(Integer id, CustomDepartmentRequest request) {
UbCustomDepartment entity = requireEditableDepartment(id);
String name = requireName(request.getName());
if (name.equals(entity.getName())) {
return toVo(entity, categoryName(entity.getCategoryId()));
}
requireNameFree(entity.getSchoolId(), entity.getCategoryId(), name, entity.getId());
entity.setName(name);
departmentRepository.save(entity);
return toVo(entity, categoryName(entity.getCategoryId()));
}
@Override
@Transactional
public CustomDepartmentVo move(Integer id, CustomDepartmentMoveRequest request) {
UbCustomDepartment entity = requireEditableDepartment(id);
UbCustomDeptCategory target = requireLiveCategory(request.getCategoryId());
// 只能移到本校其他分类:跨校目标分类直接拒绝(不按「范围里有就算」放宽)
if (!entity.getSchoolId().equals(target.getSchoolId())) {
throw new DException("只能移动到本校其他分类");
}
accessScopeService.requireSchoolInScope(target.getSchoolId());
if (target.getId().equals(entity.getCategoryId())) {
return toVo(entity, target.getName());
}
requireNameFree(entity.getSchoolId(), target.getId(), entity.getName(), entity.getId());
entity.setCategoryId(target.getId());
Integer maxSort = departmentRepository.findMaxSortNoByCategory(target.getId());
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
departmentRepository.save(entity);
return toVo(entity, target.getName());
}
@Override
@Transactional
public void sort(CustomDepartmentSortRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
List<Integer> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new DException("部门ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("部门ID列表存在重复");
}
List<UbCustomDepartment> rows = new ArrayList<>();
Map<Integer, UbCustomDepartment> byId = new HashMap<>();
for (Integer id : ids) {
UbCustomDepartment row = (id == null) ? null : departmentRepository.findById(id).orElse(null);
if (row == null || row.isDeletedRow() || !schoolId.equals(row.getSchoolId())) {
throw new DException("部门不存在或不属于本校");
}
if (request.getCategoryId() != null && !request.getCategoryId().equals(row.getCategoryId())) {
throw new DException("部门不属于该分类");
}
rows.add(row);
byId.put(row.getId(), row);
}
for (int i = 0; i < ids.size(); i++) {
byId.get(ids.get(i)).setSortNo(i + 1);
}
departmentRepository.saveAll(rows);
}
/**
* 启停翻转(范式同 {@code SysGroupService.changeStatus}):只走 {@code updateEnabled} 单列 UPDATE,
* 不动成员、不动软删列、不发事件(本期 CUSTOM 无缓存)。<br>
* ⚠ enabled 语义 1=启用/0=停用,与 sys_group.status 方向相反。
*/
@Override
@Transactional
public void changeEnabled(Integer id, boolean enabled) {
requireEditableDepartment(id);
int updated = departmentRepository.updateEnabled(id, enabled ? 1 : 0);
if (updated == 0) {
throw new DException("自定义部门不存在");
}
}
@Override
@Transactional
public void softDelete(Integer id) {
UbCustomDepartment entity = requireEditableDepartment(id);
entity.setIsDeleted(1);
// del_marker=自身 id:让出 uk(school_id, category_id, name, del_marker) 的有效位,
// 之后同名新部门走新 id,旧引用不会指向新对象(A13);成员关系保留在库由查询侧过滤(A12)
entity.setDelMarker(entity.getId() == null ? 0L : entity.getId().longValue());
entity.setDeletedAt(LocalDateTime.now());
entity.setDeletedBy(UserContext.getCurrentUser());
departmentRepository.save(entity);
}
// ===================== 成员 =====================
@Override
public List<CustomDepartmentMemberVo> members(Integer id) {
UbCustomDepartment department = requireEditableDepartment(id);
List<UbCustomDepartmentMember> rows = memberRepository.findByDepartmentId(department.getId());
if (rows.isEmpty()) {
return Collections.emptyList();
}
List<Integer> userIds = new ArrayList<>();
for (UbCustomDepartmentMember row : rows) {
userIds.add(row.getUserId());
}
Map<Integer, SysUser> userMap = new HashMap<>();
for (SysUser user : sysUserRepository.findAllById(userIds)) {
userMap.put(user.getId(), user);
}
List<CustomDepartmentMemberVo> result = new ArrayList<>();
for (Integer userId : userIds) {
SysUser user = userMap.get(userId);
// 账号已被删除的孤儿关系行不出现在成员列表(uk 仍占位,重加同 id 时按幂等跳过)
if (user == null) {
continue;
}
result.add(toMemberVo(user));
}
return result;
}
@Override
@Transactional
public void addMembers(Integer id, CustomDepartmentMemberRequest request) {
UbCustomDepartment department = requireEditableDepartment(id);
List<Integer> distinct = distinctUserIds(request.getUserIds());
Set<Integer> teacherUserIds = schoolTeacherUserIds(department.getSchoolId());
Set<Integer> existing = existingMemberUserIds(department.getId());
List<UbCustomDepartmentMember> toSave = new ArrayList<>();
for (Integer userId : distinct) {
if (!teacherUserIds.contains(userId)) {
// D4:非本校或未在教职工范围内(学生组成员)——逐个复核,任一不合格整批拒绝
throw new DException("用户「" + userLabel(userId) + "」不是本校教职工,不能加入自定义部门");
}
if (existing.contains(userId)) {
// 重复添加按幂等跳过(A08),不产生第二条有效成员关系
continue;
}
UbCustomDepartmentMember member = new UbCustomDepartmentMember();
member.setSchoolId(department.getSchoolId());
member.setDepartmentId(department.getId());
member.setUserId(userId);
toSave.add(member);
}
if (!toSave.isEmpty()) {
memberRepository.saveAll(toSave);
}
}
@Override
@Transactional
public void removeMembers(Integer id, CustomDepartmentMemberRequest request) {
UbCustomDepartment department = requireEditableDepartment(id);
List<Integer> distinct = distinctUserIds(request.getUserIds());
memberRepository.deleteByDepartmentIdAndUserIds(department.getId(), distinct);
}
@Override
public List<CustomDepartmentMemberVo> candidateUsers(Integer id, String keyword) {
UbCustomDepartment department = requireEditableDepartment(id);
Set<Integer> teacherUserIds = schoolTeacherUserIds(department.getSchoolId());
Set<Integer> existing = existingMemberUserIds(department.getId());
List<Integer> candidateIds = new ArrayList<>(teacherUserIds);
candidateIds.removeAll(existing);
if (candidateIds.isEmpty()) {
return Collections.emptyList();
}
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
List<CustomDepartmentMemberVo> result = new ArrayList<>();
for (SysUser user : sysUserRepository.findAllById(candidateIds)) {
if (!kw.isEmpty() && !matches(user, kw)) {
continue;
}
result.add(toMemberVo(user));
}
// 上限在关键字过滤之后截断(先截后滤会把命中的藏在后面);超限由前端提示继续输入关键字缩小范围
if (result.size() > CANDIDATE_LIMIT) {
return new ArrayList<>(result.subList(0, CANDIDATE_LIMIT));
}
return result;
}
private boolean matches(SysUser user, String kw) {
return (user.getAccount() != null && user.getAccount().toLowerCase().contains(kw))
|| (user.getRealName() != null && user.getRealName().toLowerCase().contains(kw));
}
// ===================== 校验与私有工具 =====================
/** 定位可操作的<b>有效</b>部门:软删行不参与常规写口(历史解析走 T2 的 resolve-batch)。 */
private UbCustomDepartment requireEditableDepartment(Integer id) {
UbCustomDepartment entity = (id == null) ? null : departmentRepository.findById(id).orElse(null);
if (entity == null || entity.isDeletedRow()) {
throw new DException("自定义部门不存在");
}
// 校维度授权以「对象所属学校」为准:范围外(含跨校)直接拒绝
accessScopeService.requireSchoolInScope(entity.getSchoolId());
return entity;
}
private UbCustomDeptCategory requireLiveCategory(Integer categoryId) {
UbCustomDeptCategory category = (categoryId == null) ? null
: categoryRepository.findById(categoryId).orElse(null);
if (category == null || category.isDeletedRow()) {
throw new DException("分类不存在");
}
accessScopeService.requireSchoolInScope(category.getSchoolId());
return category;
}
private String categoryName(Integer categoryId) {
return categoryRepository.findById(categoryId).map(UbCustomDeptCategory::getName).orElse(null);
}
/** 同校同分类有效部门重名前置校验(uk 兜底,这里给友好报错);跨分类与 SYSTEM/CUSTOM 之间允许重名(A06)。 */
private void requireNameFree(Integer schoolId, Integer categoryId, String name, Integer selfId) {
departmentRepository
.findFirstBySchoolIdAndCategoryIdAndNameAndIsDeleted(schoolId, categoryId, name, NOT_DELETED)
.ifPresent(hit -> {
if (!hit.getId().equals(selfId)) {
throw new DException("该分类下已存在同名部门「" + name + "」");
}
});
}
private List<Integer> distinctUserIds(List<Integer> userIds) {
if (userIds == null || userIds.isEmpty()) {
throw new DException("成员列表不能为空");
}
Set<Integer> distinct = new LinkedHashSet<>(userIds);
distinct.remove(null);
if (distinct.isEmpty()) {
throw new DException("成员列表不能为空");
}
return new ArrayList<>(distinct);
}
private Set<Integer> existingMemberUserIds(Integer departmentId) {
Set<Integer> existing = new HashSet<>();
for (UbCustomDepartmentMember row : memberRepository.findByDepartmentId(departmentId)) {
existing.add(row.getUserId());
}
return existing;
}
/**
* 本校教职工用户集合(D4):落在学校子树内、且<b>不属于</b>该校学生组(type=8)子树的正常账号。<br>
* 判定只依据 {@code sys_user_group_rel} + {@code sys_group.type},与「按组查用户」既有范式一致
* ({@code findByGroupIdIn} 后按 userId 去重)。
*/
private Set<Integer> schoolTeacherUserIds(Integer schoolId) {
List<Integer> schoolGroupIds = sysGroupService.listSelfAndDescendantIds(schoolId);
if (schoolGroupIds.isEmpty()) {
return Collections.emptySet();
}
Set<Integer> studentGroupIds = new LinkedHashSet<>();
for (SysGroup group : sysGroupRepository.findAllById(schoolGroupIds)) {
if (group.getType() == GroupTypeEnum.STUDENT_GROUP) {
studentGroupIds.addAll(sysGroupService.listSelfAndDescendantIds(group.getId()));
}
}
Set<Integer> excluded = new HashSet<>();
if (!studentGroupIds.isEmpty()) {
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(new ArrayList<>(studentGroupIds))) {
excluded.add(rel.getUserId());
}
}
Set<Integer> candidates = new HashSet<>();
for (SysUserGroupRel rel : sysUserGroupRelRepository.findByGroupIdIn(schoolGroupIds)) {
if (!excluded.contains(rel.getUserId())) {
candidates.add(rel.getUserId());
}
}
// 有效账号:禁用用户不能作为新成员(LinkedHashSet 保证顺序稳定,便于逐 id 报错定位)
Set<Integer> valid = new LinkedHashSet<>();
for (SysUser user : sysUserRepository.findAllById(new ArrayList<>(candidates))) {
if (user.getDisabled() != StatusEnum.DISABLED) {
valid.add(user.getId());
}
}
return valid;
}
private String userLabel(Integer userId) {
SysUser user = sysUserRepository.findById(userId).orElse(null);
if (user == null) {
return String.valueOf(userId);
}
return user.getRealName() == null ? user.getAccount() : user.getRealName();
}
private Map<Integer, Long> countMembersByDepartment(List<UbCustomDepartment> rows) {
List<Integer> ids = new ArrayList<>();
for (UbCustomDepartment row : rows) {
ids.add(row.getId());
}
Map<Integer, Long> counts = new HashMap<>();
for (UbCustomDepartmentMember member : memberRepository.findByDepartmentIdIn(ids)) {
counts.merge(member.getDepartmentId(), 1L, Long::sum);
}
return counts;
}
private String requireName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new DException("部门名称不能为空");
}
if (name.length() > 64) {
throw new DException("部门名称不能超过 64 个字符");
}
return name;
}
private CustomDepartmentVo toVo(UbCustomDepartment entity, String categoryName) {
CustomDepartmentVo vo = new CustomDepartmentVo();
vo.setId(entity.getId());
vo.setSchoolId(entity.getSchoolId());
vo.setCategoryId(entity.getCategoryId());
vo.setCategoryName(categoryName);
vo.setName(entity.getName());
vo.setSortNo(entity.getSortNo());
vo.setEnabled(entity.isEnabledRow());
return vo;
}
private CustomDepartmentMemberVo toMemberVo(SysUser user) {
CustomDepartmentMemberVo vo = new CustomDepartmentMemberVo();
vo.setUserId(user.getId());
vo.setAccount(user.getAccount());
vo.setName(user.getRealName());
return vo;
}
}
@@ -0,0 +1,185 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.common.context.UserContext;
import com.yunhai.userbase.entity.UbCustomDeptCategory;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.repository.UbCustomDeptCategoryRepository;
import com.yunhai.userbase.repository.UbCustomDepartmentRepository;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import com.yunhai.userbase.service.resolve.AccessScopeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.transaction.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理实现。<br>
* 三条铁律:① schoolId 一律由服务端从当前身份范围解析,请求体只作核对(T1.3);
* ② 软删用 is_deleted + del_marker(软删时置为行 id),name 永不改写;
* ③ 本期 CUSTOM 目录不加缓存(任务书 §0.2),故写口不发缓存驱逐事件——若后续接入缓存,
* 必须注册 CacheNames 并保证<b>全部</b>写口发事件驱逐。
*/
@Service
public class UbCustomDeptCategoryService implements ICustomDeptCategoryService {
private static final int NOT_DELETED = 0;
@Autowired
private UbCustomDeptCategoryRepository categoryRepository;
@Autowired
private UbCustomDepartmentRepository departmentRepository;
@Autowired
private AccessScopeService accessScopeService;
@Override
public List<CustomDeptCategoryVo> list(Integer schoolId) {
Integer scoped = accessScopeService.resolveWritableSchoolId(schoolId);
List<CustomDeptCategoryVo> result = new ArrayList<>();
for (UbCustomDeptCategory category : requireSchoolCategories(scoped)) {
result.add(toVo(category));
}
return result;
}
@Override
@Transactional
public CustomDeptCategoryVo add(CustomDeptCategoryRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
String name = requireName(request.getName());
if (categoryRepository.existsBySchoolIdAndNameAndIsDeleted(schoolId, name, NOT_DELETED)) {
throw new DException("本校已存在同名分类「" + name + "」");
}
UbCustomDeptCategory entity = new UbCustomDeptCategory();
entity.setSchoolId(schoolId);
entity.setName(name);
Integer maxSort = categoryRepository.findMaxSortNoBySchool(schoolId);
entity.setSortNo(maxSort == null ? 1 : maxSort + 1);
entity.setIsDeleted(NOT_DELETED);
entity.setDelMarker(0L);
categoryRepository.save(entity);
return toVo(entity);
}
@Override
@Transactional
public CustomDeptCategoryVo rename(Integer id, CustomDeptCategoryRequest request) {
UbCustomDeptCategory entity = requireEditableCategory(id);
String name = requireName(request.getName());
if (name.equals(entity.getName())) {
return toVo(entity);
}
categoryRepository.findFirstBySchoolIdAndNameAndIsDeleted(entity.getSchoolId(), name, NOT_DELETED)
.ifPresent(hit -> {
if (!hit.getId().equals(entity.getId())) {
throw new DException("本校已存在同名分类「" + name + "」");
}
});
entity.setName(name);
categoryRepository.save(entity);
return toVo(entity);
}
@Override
@Transactional
public void sort(CustomDeptCategorySortRequest request) {
Integer schoolId = accessScopeService.resolveWritableSchoolId(request.getSchoolId());
List<Integer> ids = request.getIds();
if (ids == null || ids.isEmpty()) {
throw new DException("分类ID列表不能为空");
}
if (new HashSet<>(ids).size() != ids.size()) {
throw new DException("分类ID列表存在重复");
}
List<UbCustomDeptCategory> categories = requireSchoolCategories(schoolId);
Set<Integer> scopeIds = new HashSet<>();
for (UbCustomDeptCategory category : categories) {
scopeIds.add(category.getId());
}
for (Integer id : ids) {
if (!scopeIds.contains(id)) {
throw new DException("分类不存在或不属于本校");
}
}
// 按传入顺序落库(1 起),范式同 SysGroupService.sortChildren
Map<Integer, UbCustomDeptCategory> byId = new HashMap<>();
for (UbCustomDeptCategory category : categories) {
byId.put(category.getId(), category);
}
for (int i = 0; i < ids.size(); i++) {
byId.get(ids.get(i)).setSortNo(i + 1);
}
categoryRepository.saveAll(categories);
}
@Override
@Transactional
public void softDelete(Integer id) {
UbCustomDeptCategory entity = requireEditableCategory(id);
long live = departmentRepository.countByCategoryIdAndIsDeleted(entity.getId(), NOT_DELETED);
if (live > 0) {
// A14:明确拒绝,禁止任何级联删除
throw new DException("该分类下仍有 " + live + " 个部门,请先移动或删除这些部门");
}
markDeleted(entity);
}
// ===================== 私有 =====================
/** 定位可操作的<b>有效</b>分类并做校维度授权(跨校直接拒绝,A09)。 */
private UbCustomDeptCategory requireEditableCategory(Integer id) {
UbCustomDeptCategory entity = (id == null) ? null : categoryRepository.findById(id).orElse(null);
if (entity == null || entity.isDeletedRow()) {
throw new DException("分类不存在");
}
accessScopeService.requireSchoolInScope(entity.getSchoolId());
return entity;
}
private List<UbCustomDeptCategory> requireSchoolCategories(Integer schoolId) {
return categoryRepository.findBySchoolIdAndIsDeletedOrderBySortNoAscIdAsc(schoolId, NOT_DELETED);
}
private void markDeleted(UbCustomDeptCategory entity) {
entity.setIsDeleted(1);
// del_marker=自身 id:让出 uk(school_id, name, del_marker) 的有效位,同名新分类可另起新 id(A13)
entity.setDelMarker(entity.getId() == null ? 0L : entity.getId().longValue());
entity.setDeletedAt(LocalDateTime.now());
entity.setDeletedBy(UserContext.getCurrentUser());
categoryRepository.save(entity);
}
private String requireName(String rawName) {
String name = rawName == null ? "" : rawName.trim();
if (name.isEmpty()) {
throw new DException("分类名称不能为空");
}
if (name.length() > 64) {
throw new DException("分类名称不能超过 64 个字符");
}
return name;
}
private CustomDeptCategoryVo toVo(UbCustomDeptCategory entity) {
CustomDeptCategoryVo vo = new CustomDeptCategoryVo();
vo.setId(entity.getId());
vo.setSchoolId(entity.getSchoolId());
vo.setName(entity.getName());
vo.setSortNo(entity.getSortNo());
vo.setDepartmentCount(departmentRepository.countByCategoryIdAndIsDeleted(entity.getId(), NOT_DELETED));
return vo;
}
}
@@ -363,6 +363,55 @@ public class AccessScopeService {
} }
} }
/**
* 学校维度 guard(2026-10-21 CUSTOM 部门线):schoolId 必须存在、类型为学校组,且落在当前用户数据范围内。<br>
* 现有 guard 都是「组」维度,而 CUSTOM 分类/部门只以学校这一层作组织锚点,故单独成校维度判定;
* 范围判定复用 {@link #scopeGroupIds()},与其同域(学校组 id 即范围根)。
*/
public void requireSchoolInScope(Integer schoolId) {
if (schoolId == null) {
throw new DException("学校ID不能为空");
}
SysGroup school = sysGroupRepository.findById(schoolId).orElse(null);
if (school == null) {
throw new DException("学校分组不存在");
}
if (school.getType() != GroupTypeEnum.SCHOOL_GROUP) {
throw new DException("学校ID必须为学校分组");
}
requireGroupInScope(schoolId);
}
/**
* 解析写口的生效学校 id —— CUSTOM 侧「schoolId 取自当前身份范围,请求体只作核对不作授权」的唯一入口。<br>
* 分级语义:SCHOOL 只能落在校级可管理学校集合内(多校管理员须显式指定且必须命中其一,未指定则要求补齐);
* SUPER 不限制、BUREAU 限区域内学校,二者均以请求值为准并由 {@link #requireSchoolInScope} 兜范围。
*
* @param requestedSchoolId 前端传来的学校 id(可为 null:单校管理员直接取本校)
* @return 服务端认定的权威学校 id
*/
public Integer resolveWritableSchoolId(Integer requestedSchoolId) {
AccessScope scope = resolveCurrentScope();
if (scope.isNone()) {
throw new DException("暂无权限");
}
if (scope.getLevel() == AccessLevelEnum.SCHOOL) {
List<Integer> mine = scope.schoolGroupIds();
if (requestedSchoolId != null) {
if (!mine.contains(requestedSchoolId)) {
throw new DException("暂无权限访问该组织");
}
return requestedSchoolId;
}
if (mine.size() != 1) {
throw new DException("请选择要管理的学校");
}
return mine.get(0);
}
requireSchoolInScope(requestedSchoolId);
return requestedSchoolId;
}
/** /**
* 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。<br> * 目标用户的归属组必须与当前用户数据范围有交集(SUPER 不限制)。<br>
* 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。 * 用于禁用/启用等「入参只有 userId」的接口,防止跨校操作他人账号;无任何组关系的用户视为越界。
@@ -379,6 +379,8 @@ public class FileUtil {
/** /**
* MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。<br> * MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。<br>
* svg 后缀走白名单消毒流程(AppIcon 方案 P4):≤64KB + viewBox ≤1024 + 元素/属性白名单消毒,
* 落盘的是消毒后内容——不把 svg 加进危险后缀黑名单(正常图标功能保留)。<br>
* *
* @param file 上传文件 * @param file 上传文件
* @param subDir 子目录 * @param subDir 子目录
@@ -394,14 +396,30 @@ public class FileUtil {
ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase(); ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase();
// 后缀黑名单校验 // 后缀黑名单校验
validateExt(ext); validateExt(ext);
// svg 消毒(含 64KB/viewBox 校验):落盘消毒后字节,而非原始上传内容
byte[] sanitized = null;
if (".svg".equals(ext)) {
try {
String raw = new String(file.getBytes(), java.nio.charset.StandardCharsets.UTF_8);
sanitized = SvgSanitizer.sanitize(raw).getBytes(java.nio.charset.StandardCharsets.UTF_8);
} catch (DException e) {
throw e;
} catch (Exception e) {
throw new DException("SVG 文件处理失败");
}
}
ensureStoreBase(); ensureStoreBase();
try { try {
String dirPath = BASE_STORE_PATH + File.separator + subDir; String dirPath = BASE_STORE_PATH + File.separator + subDir;
cn.hutool.core.io.FileUtil.mkdir(dirPath); cn.hutool.core.io.FileUtil.mkdir(dirPath);
String fileName = IdUtil.fastSimpleUUID() + ext; String fileName = IdUtil.fastSimpleUUID() + ext;
File targetFile = new File(dirPath, fileName); File targetFile = new File(dirPath, fileName);
// 流式写入,不加载全文件到内存 if (sanitized != null) {
cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile); cn.hutool.core.io.FileUtil.writeBytes(sanitized, targetFile);
} else {
// 流式写入,不加载全文件到内存
cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile);
}
return buildUrl(subDir, fileName); return buildUrl(subDir, fileName);
} catch (DException e) { } catch (DException e) {
throw e; throw e;
@@ -0,0 +1,243 @@
package com.yunhai.userbase.utils;
import com.yunhai.platform.utils.exception.DException;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import java.io.ByteArrayInputStream;
import java.io.StringWriter;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
/**
* 上传 SVG 白名单式消毒器(AppIcon 方案 P4 安全收口)。<br>
* 背景:上传框早已 accept=".svg" 且 /upload/icon/** 同源可访问,存储型 XSS(如
* {@code <svg onload=alert(1)>})此前无任何拦截——本类在落盘前消毒,而非把 svg 加进
* 危险后缀黑名单(那会砍掉正常功能)。<br>
* 策略(白名单优先,不依赖 jsoup/dom4j 等额外依赖):
* <ul>
* <li>元素白名单:svg 结构/图形/渐变/文本/滤镜元素之外(script、foreignObject、animate、
* image、a、switch 等)整棵子树删除;</li>
* <li>属性白名单 + 三条硬规则:on* 事件属性一律删、style(可携带 url(javascript:))一律删、
* href/xlink:href 仅保留 "#" 开头的文档内引用(外部引用一律删);</li>
* <li>注释/PI/CDATA 节点删除;DOCTYPE/外部实体在解析层直接禁止(XXE);</li>
* <li>消毒产物重新解析校验——仍是合法 XML 且根元素为 svg 才放行;</li>
* <li>尺寸校验:字节数 ≤64KB;viewBox 宽高 ≤1024(缺失 viewBox 不阻断,仅校验存在的值)。</li>
* </ul>
*/
public final class SvgSanitizer {
/** 图标上传字节数上限(与前端 before-upload 同口径) */
public static final int MAX_BYTES = 64 * 1024;
/** viewBox 宽/高上限(AppIcon 资产库 1024 网格口径) */
public static final double MAX_VIEWBOX = 1024.0;
/** 元素白名单(统一小写比较;SVG 局部名大小写敏感,比较前折叠) */
private static final Set<String> ALLOWED_ELEMENTS = new HashSet<>(Arrays.asList(
"svg", "g", "defs", "symbol", "use", "path", "rect", "circle", "ellipse", "line",
"polyline", "polygon", "text", "tspan", "textpath",
"lineargradient", "radialgradient", "stop", "clippath", "mask", "pattern", "marker",
"title", "desc",
"filter", "fegaussianblur", "feoffset", "feblend", "fecolormatrix", "fecomposite",
"feflood", "femerge", "femergenode", "fecomponenttransfer",
"fefuncr", "fefuncg", "fefuncb", "fefuna"));
/** 属性白名单(统一小写比较;href 走专用规则,不在此集合;不收带前缀属性——序列化层对非 ns-aware DOM 的前缀属性不稳定,内部引用统一规范为无前缀 href) */
private static final Set<String> ALLOWED_ATTRS = new HashSet<>(Arrays.asList(
"id", "class", "version", "baseprofile", "xmlns", "preserveaspectratio",
"viewbox", "width", "height", "x", "y", "dx", "dy",
"d", "points", "transform", "clip-path", "mask", "filter",
"fill", "fill-opacity", "fill-rule", "color", "opacity",
"stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit",
"stroke-dasharray", "stroke-dashoffset", "stroke-opacity", "clip-rule",
"cx", "cy", "r", "rx", "ry", "x1", "y1", "x2", "y2",
"gradientunits", "gradienttransform", "spreadmethod", "offset", "stop-color", "stop-opacity",
"patternunits", "patterncontentunits", "patterntransform",
"markerwidth", "markerheight", "refx", "refy", "orient", "markerunits",
"text-anchor", "font-family", "font-size", "font-weight", "font-style",
"letter-spacing", "word-spacing", "dominant-baseline", "alignment-baseline",
"rotate", "textlength", "lengthadjust", "display", "visibility"));
private SvgSanitizer() {
}
/**
* 消毒上传 SVG 并执行尺寸校验,返回可安全落盘的内容(UTF-8 文本)。<br>
* 任何解析失败/结构异常/超限均抛 {@link DException}(提示词面相用户,不透出内部栈)。
*
* @param raw 原始 SVG 文本
* @return 消毒后的 SVG 文本(合法 XML,根元素 svg)
*/
public static String sanitize(String raw) {
if (raw == null || raw.trim().isEmpty()) {
throw new DException("SVG 内容为空");
}
byte[] bytes = raw.getBytes(StandardCharsets.UTF_8);
if (bytes.length > MAX_BYTES) {
throw new DException("SVG 文件超过 64KB 上限");
}
Document doc = parse(raw);
Element root = doc.getDocumentElement();
if (root == null || !"svg".equals(localName(root.getNodeName()))) {
throw new DException("SVG 根元素必须是 <svg>");
}
checkViewBox(root);
stripDisallowed(doc);
String out = serialize(doc);
// 消毒产物复解析:保证落盘的仍是合法 svg(白名单误删导致结构破坏时在此拦截)
Element reroot = parse(out).getDocumentElement();
if (reroot == null || !"svg".equals(localName(reroot.getNodeName()))) {
throw new DException("SVG 消毒后校验失败");
}
return out;
}
/** 深度遍历:删白名单外元素整棵子树 + 清洗保留元素属性 + 删注释/PI/CDATA。 */
private static void stripDisallowed(Node node) {
NodeList children = node.getChildNodes();
for (int i = children.getLength() - 1; i >= 0; i--) {
Node c = children.item(i);
short type = c.getNodeType();
if (type == Node.ELEMENT_NODE) {
if (!ALLOWED_ELEMENTS.contains(localName(c.getNodeName()))) {
node.removeChild(c);
} else {
stripDisallowed(c);
}
} else if (type == Node.COMMENT_NODE || type == Node.PROCESSING_INSTRUCTION_NODE
|| type == Node.CDATA_SECTION_NODE) {
node.removeChild(c);
}
}
if (node.getNodeType() == Node.ELEMENT_NODE) {
cleanAttrs((Element) node);
}
}
/** 属性清洗:on 开头的事件属性与 style 一律删、href 仅留内部锚点、其余按白名单。 */
private static void cleanAttrs(Element el) {
NamedNodeMap attrs = el.getAttributes();
for (int i = attrs.getLength() - 1; i >= 0; i--) {
Node a = attrs.item(i);
String name = a.getNodeName(); // 可能带前缀(xlink:href)
String local = localName(name);
if (local.startsWith("on") || "style".equals(local)) {
el.removeAttribute(name);
continue;
}
if ("href".equals(local)) {
String value = a.getNodeValue().trim();
if (!value.startsWith("#")) {
el.removeAttribute(name); // 外部引用(http/data/javascript: 等)一律删
} else if (!"href".equals(name)) {
// 内部引用:规范为无前缀 href(SVG2 形式,浏览器全兼容;
// 非 ns-aware DOM 序列化带前缀属性不稳定,且 xlink 声明可能已被裁掉)
el.removeAttribute(name);
el.setAttribute("href", value);
}
continue;
}
if (!ALLOWED_ATTRS.contains(local)) {
el.removeAttribute(name);
}
}
}
/** viewBox 校验:宽高均须为正数且 ≤1024;缺失不阻断(存量无 viewBox 图标功能不砍)。 */
private static void checkViewBox(Element root) {
String vb = attrIgnoreCase(root, "viewBox");
if (vb == null || vb.trim().isEmpty()) {
return;
}
String[] parts = vb.trim().split("[\\s,]+");
if (parts.length != 4) {
throw new DException("SVG viewBox 格式不合法");
}
try {
double w = Double.parseDouble(parts[2]);
double h = Double.parseDouble(parts[3]);
if (w <= 0 || h <= 0) {
throw new DException("SVG viewBox 宽高必须为正数");
}
if (w > MAX_VIEWBOX || h > MAX_VIEWBOX) {
throw new DException("SVG viewBox 宽高不能超过 1024");
}
} catch (NumberFormatException e) {
throw new DException("SVG viewBox 格式不合法");
}
}
private static String attrIgnoreCase(Element el, String name) {
NamedNodeMap attrs = el.getAttributes();
for (int i = 0; i < attrs.getLength(); i++) {
Node a = attrs.item(i);
if (localName(a.getNodeName()).equals(localName(name))) {
return a.getNodeValue();
}
}
return null;
}
/** 解析(禁 DOCTYPE/外部实体/XXE)。解析失败 = 文件不是合法 XML,直接拒绝。 */
private static Document parse(String xml) {
try {
DocumentBuilderFactory f = DocumentBuilderFactory.newInstance();
f.setNamespaceAware(false);
f.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
f.setFeature("http://xml.org/sax/features/external-general-entities", false);
f.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
f.setXIncludeAware(false);
f.setExpandEntityReferences(false);
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
f.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
return f.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
} catch (DException e) {
throw e;
} catch (Exception e) {
throw new DException("SVG 文件解析失败(不是合法的 SVG)");
}
}
/** 序列化(无 XML 声明头、UTF-8;浏览器可直接渲染)。 */
private static String serialize(Document doc) {
try {
TransformerFactory tf = TransformerFactory.newInstance();
tf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
tf.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
Transformer t = tf.newTransformer();
t.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
t.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
t.setOutputProperty(OutputKeys.METHOD, "xml");
StringWriter sw = new StringWriter();
t.transform(new DOMSource(doc), new StreamResult(sw));
return sw.toString();
} catch (Exception e) {
throw new DException("SVG 消毒序列化失败");
}
}
/** 名字折叠:取冒号后局部名并转小写(namespaceAware=false 时 nodeName 可能带前缀)。 */
private static String localName(String name) {
if (name == null) {
return "";
}
int idx = name.indexOf(':');
String local = idx >= 0 ? name.substring(idx + 1) : name;
return local.toLowerCase(Locale.ROOT);
}
}
+1 -1
View File
@@ -70,7 +70,7 @@ yh:
enabled: true enabled: true
identity: identity:
# 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增) # 允许调用 oapi/identity/switch 的应用 appKey 白名单(逗号分隔;100001=UserHome,后期会新增)
switch-allowed-app-keys: 100001,100002 switch-allowed-app-keys: 100001,100002,100009
# 接口文档(springdoc):分组见 SwaggerConfig(oapi=开放接口契约权威源 / api=业务接口) # 接口文档(springdoc):分组见 SwaggerConfig(oapi=开放接口契约权威源 / api=业务接口)
# 契约快照导出:GET /v3/api-docs/oapi # 契约快照导出:GET /v3/api-docs/oapi
@@ -0,0 +1,45 @@
-- ============================================================
-- 2026-09-28 应用身份统一:app_code(技术标识)+ app_nature(应用性质)
-- 方案:《消息卡片直批与跳转基址统一方案-20260928.md》§4 / §11.4
-- 库:160 生产 userbase 库(实测名 proj18;执行前 SHOW DATABASES 再核对)
-- 执行顺序不可颠倒:加列 → 回填 → 校验 → 收紧(最后才允许建 UserHome 行,另行立项)
-- 幂等约定:每段执行前先 SHOW COLUMNS FROM application_info LIKE '<列>'; 已存在则跳过该段。
-- ============================================================
-- ---------- 1. 加列(可 NULL,过渡期约束靠应用层) ----------
ALTER TABLE application_info
ADD COLUMN app_nature VARCHAR(16) NULL COMMENT '应用性质:BASE=基石 / BUSINESS=业务',
ADD COLUMN app_code VARCHAR(32) NULL COMMENT '技术标识=projman app.name(跳转查询键)',
ADD COLUMN miniprogram_url VARCHAR(255) NULL COMMENT '微信小程序应用地址';
-- ---------- 2. 回填 app_nature ----------
-- 存量 15 行全为业务应用;UserBase 自身(100000)不标 BASE(由 appKey<=100000 条件一覆盖),统一 BUSINESS。
UPDATE application_info SET app_nature = 'BUSINESS' WHERE app_nature IS NULL;
-- ---------- 3. 回填 app_code ----------
-- 3.1 自动段:projman.app.project_name = userbase.name 精确命中(实测 11 条)。
-- ⚠️ 执行前核对 projman 库名(下方写的是仓库配置值 bsch_projman,160 实际名以 SHOW DATABASES 为准);
-- 若 projman 库不在同一 MySQL 实例,则先在 projman 侧查出 (project_name, name) 清单,改用 3.2 的逐行形式。
UPDATE application_info u
JOIN bsch_projman.app p ON p.project_name = u.name
SET u.app_code = p.name
WHERE u.app_code IS NULL;
-- 3.2 人工段:中文名与 projman 不对应(方案 §4.3 台账),显式指定
UPDATE application_info SET app_code = 'userbase' WHERE name = '用户中心平台' AND app_code IS NULL;
UPDATE application_info SET app_code = 'userclass' WHERE name = '学校管理' AND app_code IS NULL;
UPDATE application_info SET app_code = 'docprint' WHERE name = '文印申请' AND app_code IS NULL;
-- 3.3 孤儿:成绩管理无 projman 部署,保留 NULL 观察(用户已定,勿回填)
-- ---------- 4. 校验(预期:总 15 行;app_code NULL 恰 1 行且为「成绩管理」;app_nature NULL 为 0) ----------
SELECT COUNT(*) AS total_rows,
SUM(app_code IS NULL) AS code_null_cnt,
SUM(app_code IS NULL AND name <> '成绩管理') AS code_null_unexpected,
SUM(app_nature IS NULL) AS nature_null_cnt
FROM application_info;
-- ---------- 5. 收紧 ----------
-- app_code 加 UNIQUE(应用层 add/edit 已做重复校验,此处落库兜底)。
-- NOT NULL 暂缓:成绩管理孤儿行按约定保留 NULL,待其定论(上线或删除)后再收紧。
ALTER TABLE application_info ADD UNIQUE KEY uk_application_app_code (app_code);
@@ -0,0 +1,14 @@
-- app_module.custom_id 从 NOT NULL 放开为可空:子模块双定位(树节点 / 按钮权限)。
-- 背景:子模块设计上既可作树节点(有路由路径+业务编码),也可作按钮权限(两者皆无)。
-- 路径 path 本就可空;custom_id 原为 NOT NULL + 应用内唯一键,挡死了按钮权限型子模块。
-- 关键:必须存 NULL 不能存 ''——'' 在唯一键 uk_app_module_custom(app_id, custom_id) 下
-- 每个应用只能有一条,第二个按钮型子模块即撞键;NULL 在 MySQL 唯一索引中可多行并存。
-- 执行方式:dev / prod 手工执行(ddl-auto=update 只加列不改既有列属性,不会自动放 nullable)。
-- 幂等:可重复执行,重复 MODIFY 到相同定义是无害 no-op。
ALTER TABLE app_module MODIFY COLUMN custom_id VARCHAR(64) NULL COMMENT '自定义ID(应用内唯一,业务编码);null=按钮权限型子模块无编码';
-- 核对:custom_id 的 IS_NULLABLE 应为 YES
-- SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT
-- FROM information_schema.COLUMNS
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'app_module' AND COLUMN_NAME = 'custom_id';
@@ -0,0 +1,43 @@
package com.yunhai.userbase.constant;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* AppNature 判定语义单测(方案 §8 用例:基石应用两条件取「或」)。
*/
class AppNatureTest {
@Test
void isBaseApp_三档标准用例() {
assertTrue(AppNature.isBaseApp(100000, AppNature.BUSINESS.name()), "UserBase 自身:条件一命中");
assertTrue(AppNature.isBaseApp(100015, AppNature.BASE.name()), "显式标记 BASE:条件二命中");
assertFalse(AppNature.isBaseApp(100001, AppNature.BUSINESS.name()), "普通业务应用:两条件均不命中");
}
@Test
void isUserbaseSelf_null与边界() {
assertFalse(AppNature.isUserbaseSelf(null));
assertTrue(AppNature.isUserbaseSelf(100000));
assertFalse(AppNature.isUserbaseSelf(100001));
// 理论不存在(appKey 自增起点 100000),防御性覆盖边界语义
assertTrue(AppNature.isUserbaseSelf(99999));
}
@Test
void isMarkedBase_严格等值() {
assertTrue(AppNature.isMarkedBase("BASE"));
assertFalse(AppNature.isMarkedBase(null));
assertFalse(AppNature.isMarkedBase("BUSINESS"));
assertFalse(AppNature.isMarkedBase("base"), "大小写敏感,与枚举 name 严格相等");
}
@Test
void isBaseApp_条件一或二任一命中() {
assertTrue(AppNature.isBaseApp(100000, null), "UserBase 自身无需标记");
assertTrue(AppNature.isBaseApp(null, "BASE"), "appKey 缺失但显式标记 BASE 仍算基石");
assertFalse(AppNature.isBaseApp(null, null), "双缺省按业务应用放行(⑤ 原行为:appKey null 进授权范围)");
}
}
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* /oapi/app/authorized-codes 判权端点单测(消息跳转收口 C1)。<br>
* 控制器薄委托:判权来源收窄重载 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}
* (2026-10-12 硬闸门 D2 收敛,走当前身份校兜底;超管返回全部 / 普通用户返回子集 / 空权限返回空),
* 数据面 {@code listAppCodesByIds} 映射 app_code(空值跳过/去重语义见 ApplicationInfoLookupTest)。
*/
@DisplayName("AppController.authorizedCodes - 有权 app_code 集合")
class AppControllerAuthorizedCodesTest {
@InjectMocks
private AppController controller;
@Mock
private IGroupAuthzService groupAuthzService;
@Mock
private IApplicationInfoService applicationInfoService;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
@Test
void 超管_返回全部应用code() {
when(groupAuthzService.findUserAuthorizedAppIds(10006, null)).thenReturn(Arrays.asList(100011, 100013, 100015));
when(applicationInfoService.listAppCodesByIds(Arrays.asList(100011, 100013, 100015)))
.thenReturn(Arrays.asList("flow-center", "notice", "userhome"));
List<String> codes = controller.authorizedCodes(10006);
assertEquals(Arrays.asList("flow-center", "notice", "userhome"), codes);
verify(applicationInfoService).listAppCodesByIds(Arrays.asList(100011, 100013, 100015));
}
@Test
void 普通用户_仅返回有权子集() {
when(groupAuthzService.findUserAuthorizedAppIds(201, null)).thenReturn(Collections.singletonList(100013));
when(applicationInfoService.listAppCodesByIds(Collections.singletonList(100013)))
.thenReturn(Collections.singletonList("notice"));
assertEquals(Collections.singletonList("notice"), controller.authorizedCodes(201));
}
@Test
void 空权限_返回空集合() {
when(groupAuthzService.findUserAuthorizedAppIds(202, null)).thenReturn(Collections.emptyList());
when(applicationInfoService.listAppCodesByIds(Collections.emptyList()))
.thenReturn(Collections.emptyList());
assertEquals(Collections.emptyList(), controller.authorizedCodes(202));
verify(groupAuthzService).findUserAuthorizedAppIds(202, null);
verify(applicationInfoService).listAppCodesByIds(Collections.emptyList());
}
}
@@ -0,0 +1,83 @@
package com.yunhai.userbase.model.application;
import com.yunhai.userbase.common.validation.ValidationGroups;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import javax.validation.ConstraintViolation;
import javax.validation.Validation;
import javax.validation.Validator;
import javax.validation.ValidatorFactory;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* app_code / app_nature 编辑必填校验组单测(消息方案 C5,2026-09-28 拍板:新增/编辑均必填,
* 存量 null 历史行不追溯)。Create 组此前已必填;本测试冻结 Update 组同口径——
* 编辑提交置空 → 校验失败;带值 → 通过。学校模式覆盖线由前端分支原样回传原值满足校验
* (后端 editAppForSchool 只读 name/desc/pcIcon,其余字段忽略)。
*/
@DisplayName("ApplicationInfoRequest - app_code/app_nature 新增与编辑均必填")
class ApplicationInfoRequestValidationTest {
private static Validator validator;
@BeforeAll
static void setUp() {
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
validator = factory.getValidator();
}
private ApplicationInfoRequest validRequest() {
ApplicationInfoRequest request = new ApplicationInfoRequest();
request.setId(100011);
request.setName("流程中心");
request.setCategoryId(2);
request.setAppCode("flow-center");
request.setAppNature("BUSINESS");
return request;
}
private static boolean hasViolation(Set<ConstraintViolation<ApplicationInfoRequest>> violations, String field) {
return violations.stream().anyMatch(v -> v.getPropertyPath().toString().equals(field));
}
@Test
void edit_空appCode_校验失败() {
ApplicationInfoRequest request = validRequest();
request.setAppCode(" ");
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
validator.validate(request, ValidationGroups.Update.class);
assertTrue(hasViolation(violations, "appCode"), "编辑时技术标识置空必须被 Update 组拦截");
}
@Test
void edit_空appNature_校验失败() {
ApplicationInfoRequest request = validRequest();
request.setAppNature(null);
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
validator.validate(request, ValidationGroups.Update.class);
assertTrue(hasViolation(violations, "appNature"), "编辑时应用性质置空必须被 Update 组拦截");
}
@Test
void edit_带appCodeAppNature_通过() {
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
validator.validate(validRequest(), ValidationGroups.Update.class);
assertFalse(hasViolation(violations, "appCode"));
assertFalse(hasViolation(violations, "appNature"));
}
@Test
void create_空appCode_校验失败_口径不变() {
ApplicationInfoRequest request = validRequest();
request.setId(null);
request.setAppCode(null);
Set<ConstraintViolation<ApplicationInfoRequest>> violations =
validator.validate(request, ValidationGroups.Create.class);
assertTrue(hasViolation(violations, "appCode"), "新增必填为既有口径,防回归");
}
}
@@ -0,0 +1,141 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.entity.AppCategory;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.AppCategoryRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysUserGroupRelRepository;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.userbase.service.ISysGroupService;
import com.yunhai.userbase.service.resolve.MainGroupResolver;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.Mockito.when;
/**
* AppCategoryService.listBoaAppType 基石应用过滤单元测试(2026-10-08 修复)。<br>
* 契约:用户端应用列表(门户 /apps)不出现基石应用 ——
* <ul>
* <li>UserBase 自身(appKey &le; 100000)被排除(超管分支 findUserAuthorizedAppIds 返回全量时的兜底);</li>
* <li>app_nature=BASE(UserHome / 移动门户)被排除;</li>
* <li>BUSINESS 应用保留;</li>
* <li>无基石行时输出与改动前逐项等价(不误伤)。</li>
* </ul>
*/
class AppCategoryServiceBaseAppFilterTest {
@InjectMocks
private AppCategoryService appCategoryService;
@Mock
private AppCategoryRepository appCategoryRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private IGroupAuthzService groupAuthzService;
@Mock
private SchoolAppOverrideService schoolAppOverrideService;
@Mock
private IdentityService identityService;
@Mock
private MainGroupResolver mainGroupResolver;
@Mock
private ISysGroupService sysGroupService;
@Mock
private SysUserGroupRelRepository sysUserGroupRelRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
AppCategory category = new AppCategory();
category.setId(10);
category.setName("办公管理");
category.setSortNo(1);
when(appCategoryRepository.findAllByOrderBySortNoAsc()).thenReturn(Collections.singletonList(category));
// 无学校语境(平台账号):identity 无、主归属无 → applyAlias 不合并,直接看构建结果
when(identityService.getCurrent(1)).thenReturn(null);
when(mainGroupResolver.resolve(1)).thenReturn(Optional.empty());
// 合并口透传第一参数(本测试只断言过滤后的构建结果)
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
.thenAnswer(inv -> inv.getArgument(0));
}
/** 基石三类:UserBase 自身(appKey=100000)/ app_nature=BASE 均排除,BUSINESS 保留 */
@Test
void baseApps_excluded_businessKept() {
List<ApplicationInfo> apps = Arrays.asList(
app(1, 100000, null, "用户中心"),
app(2, 100001, "BASE", "用户之家"),
app(3, 100002, "BUSINESS", "通知公告"));
when(applicationInfoRepository.findAll()).thenReturn(apps);
// 超管分支:授权集合为全量(基石也在内)——正是 bug1 的触发条件(收窄重载 null 锚点=超管 bypass)
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Arrays.asList(1, 2, 3));
List<BoaAppType> result = appCategoryService.listBoaAppType(1, null, null);
assertEquals(1, result.size());
List<BoaApp> kept = result.get(0).getApps();
assertEquals(1, kept.size());
assertEquals(Integer.valueOf(3), kept.get(0).getId());
assertEquals("通知公告", kept.get(0).getName());
}
/** 无基石行时过滤逻辑零影响:全部 BUSINESS 应用按原顺序保留 */
@Test
void noBaseApps_allKeptUnchanged() {
when(applicationInfoRepository.findAll()).thenReturn(Arrays.asList(
app(3, 100002, "BUSINESS", "通知公告"),
app(4, null, null, "老应用")));
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Arrays.asList(3, 4));
List<BoaApp> kept = appCategoryService.listBoaAppType(1, null, null).get(0).getApps();
assertEquals(2, kept.size());
assertEquals(Integer.valueOf(3), kept.get(0).getId());
assertEquals(Integer.valueOf(4), kept.get(1).getId());
}
/** 无权限 / 无客户端命中的应用原本就被挡(回归:基石过滤不改变这两条既有口径) */
@Test
void unauthorizedBaseApp_stillNoneKept() {
when(applicationInfoRepository.findAll())
.thenReturn(new ArrayList<>(Collections.singletonList(app(2, 100001, "BASE", "用户之家"))));
when(groupAuthzService.findUserAuthorizedAppIds(1, null)).thenReturn(Collections.singletonList(2));
assertEquals(0, appCategoryService.listBoaAppType(1, null, null).get(0).getApps().size());
}
private static ApplicationInfo app(Integer id, Integer appKey, String appNature, String name) {
ApplicationInfo app = new ApplicationInfo();
app.setId(id);
app.setAppKey(appKey);
app.setAppNature(appNature);
app.setName(name);
app.setCategoryId(10);
app.setSortNo(id);
return app;
}
}
@@ -31,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -93,7 +94,8 @@ class AppCategoryServiceSchoolAliasTest {
app.setName("通知公告"); app.setName("通知公告");
app.setSortNo(1); app.setSortNo(1);
when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app)); when(applicationInfoRepository.findAll()).thenReturn(Collections.singletonList(app));
when(groupAuthzService.findUserAuthorizedAppIds(1)).thenReturn(Collections.singletonList(100)); // 权限集合改用学校收窄重载(2026-10-12 硬闸门);本测试只断言生效学校解析,schoolId 传 null 覆盖
when(groupAuthzService.findUserAuthorizedAppIds(eq(1), any())).thenReturn(Collections.singletonList(100));
// 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest) // 合并口透传第一参数(本测试只断言入参,合并行为见 SchoolAppOverrideServiceApplyAliasTest)
when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any())) when(schoolAppOverrideService.applyAlias(anyList(), anyMap(), any()))
.thenAnswer(inv -> inv.getArgument(0)); .thenAnswer(inv -> inv.getArgument(0));
@@ -0,0 +1,108 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userbase.entity.AppModule;
import com.yunhai.userbase.model.appmodule.AppModuleRequest;
import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysGroupModulePermRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 按钮权限型子模块(子模块双定位:树节点/按钮权限)——customId/path 可空。<br>
* 覆盖:空白 customId/path 归一为 null;空白 customId 跳过应用内唯一校验;
* 有值 customId 仍查重;编辑清空编码生效。背景见 sql/app_module_custom_id_nullable.sql
* ('' 在 uk_app_module_custom 下每应用仅一条,按钮型必须存 NULL)。
*/
class AppModuleServiceButtonModuleTest {
@InjectMocks
private AppModuleService appModuleService;
@Mock
private AppModuleRepository appModuleRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private SysGroupModulePermRepository sysGroupModulePermRepository;
@Mock
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private AppModuleRequest request(String name, String customId, String path) {
AppModuleRequest request = new AppModuleRequest();
request.setAppId(10);
request.setName(name);
request.setCustomId(customId);
request.setPath(path);
return request;
}
@Test
void add_buttonModule_blankCustomIdAndPath_normalizedToNull() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
appModuleService.add(request("删除按钮", " ", ""));
ArgumentCaptor<AppModule> captor = ArgumentCaptor.forClass(AppModule.class);
verify(appModuleRepository).save(captor.capture());
assertNull(captor.getValue().getCustomId());
assertNull(captor.getValue().getPath());
// 空白编码不进唯一性查询('' 会撞 uk_app_module_custom,必须以 NULL 落库)
verify(appModuleRepository, never()).existsByAppIdAndCustomId(anyInt(), anyString());
}
@Test
void add_withCustomId_stillCheckedForUniqueness() {
when(applicationInfoRepository.existsById(10)).thenReturn(true);
when(appModuleRepository.existsByAppIdAndCustomId(10, "btn-save")).thenReturn(true);
DException ex = assertThrows(DException.class,
() -> appModuleService.add(request("保存按钮", "btn-save", null)));
assertEquals("自定义ID在该应用下已存在", ex.getMessage());
verify(appModuleRepository, never()).save(any(AppModule.class));
}
@Test
void edit_clearCustomIdAndPath_setsNullWithoutUniquenessQuery() {
AppModule entity = new AppModule();
entity.setId(1);
entity.setAppId(10);
entity.setName("编辑按钮");
entity.setCustomId("btn-edit");
entity.setPath("/app/edit");
when(appModuleRepository.findById(1)).thenReturn(Optional.of(entity));
appModuleService.edit(1, request("编辑按钮", "", " "));
assertNull(entity.getCustomId());
assertNull(entity.getPath());
verify(appModuleRepository, never()).existsByAppIdAndCustomIdAndIdNot(anyInt(), anyString(), anyInt());
verify(appModuleRepository).save(entity);
}
}
@@ -0,0 +1,89 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.entity.SysAppRole;
import com.yunhai.userbase.model.approle.AppRoleAuthzAppVo;
import com.yunhai.userbase.repository.AppModuleRepository;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.userbase.repository.SysAppRoleModuleRelRepository;
import com.yunhai.userbase.repository.SysAppRoleRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
/**
* ⑤ 全应用角色授权范围基石排除单测(方案 §8 口径差异用例)。<br>
* 判定 = AppNature.isBaseApp(UserBase 自身 ∪ 显式 BASE);appKey=null 且未标记 BASE 的行
* 沿用原行为(appKey &gt; 100000 的原始条件对 null 求值即入列)。
*/
class AppRoleServiceBaseAppExcludeTest {
@InjectMocks
private AppRoleService appRoleService;
@Mock
private SysAppRoleRepository sysAppRoleRepository;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private AppModuleRepository appModuleRepository;
@Mock
private SysAppRoleModuleRelRepository sysAppRoleModuleRelRepository;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private ApplicationInfo app(int id, Integer appKey, String name, String nature) {
ApplicationInfo a = new ApplicationInfo();
a.setId(id);
a.setAppKey(appKey);
a.setName(name);
a.setAppNature(nature);
return a;
}
@Test
void findRoleModuleTree_全应用角色排除基石_保留业务与null缺省行() {
SysAppRole role = new SysAppRole();
role.setId(7);
role.setAllAppFlag(true);
List<ApplicationInfo> all = Arrays.asList(
app(1, 100000, "用户中心平台", null), // UserBase 自身 → 排除
app(2, 100001, "通知公告", "BUSINESS"), // 业务 → 保留
app(3, 100015, "用户主页", "BASE"), // 显式基石 → 排除(④ 与 ①②③⑤ 口径差异的关键行)
app(4, null, "遗留无key行", null), // 双缺省 → 原行为保留
app(5, null, "标记BASE无key行", "BASE")); // 显式基石 → 排除
when(sysAppRoleRepository.findById(7)).thenReturn(Optional.of(role));
when(applicationInfoRepository.findAll()).thenReturn(all);
when(appModuleRepository.findByAppIdIn(anyList())).thenReturn(Collections.emptyList());
when(sysAppRoleModuleRelRepository.findByRoleId(7)).thenReturn(Collections.emptyList());
List<AppRoleAuthzAppVo> result = appRoleService.findRoleModuleTree(7);
List<String> names = result.stream().collect(
java.util.stream.Collectors.mapping(AppRoleAuthzAppVo::getName, java.util.stream.Collectors.toList()));
assertTrue(names.contains("通知公告"), "业务应用必须可见");
assertTrue(names.contains("遗留无key行"), "appKey=null 未标记行沿用原行为");
assertFalse(names.contains("用户中心平台"), "UserBase 自身排除");
assertFalse(names.contains("用户主页"), "显式 BASE 排除——授权范围不见基石");
assertFalse(names.contains("标记BASE无key行"), "显式 BASE 排除与 appKey 无关");
}
}
@@ -0,0 +1,84 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.ApplicationInfoRequest;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import com.yunhai.platform.utils.exception.DException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ApplicationInfoAppCodeTest {
private ApplicationInfoService service;
private ApplicationInfoRepository repository;
private ApplicationInfo entity;
@BeforeEach
void setUp() {
service = new ApplicationInfoService();
repository = mock(ApplicationInfoRepository.class);
ReflectionTestUtils.setField(service, "applicationInfoRepository", repository);
ReflectionTestUtils.setField(service, "eventPublisher", mock(ApplicationEventPublisher.class));
entity = new ApplicationInfo();
entity.setId(1); entity.setAppCode("svc-hall");
when(repository.findById(1)).thenReturn(Optional.of(entity));
when(repository.save(any(ApplicationInfo.class))).thenAnswer(invocation -> invocation.getArgument(0));
}
private ApplicationInfoRequest request(String code) {
ApplicationInfoRequest r = new ApplicationInfoRequest();
r.setName("display-name"); r.setAppCode(code);
return r;
}
@Test
void editRejectsRenameBeforeMutatingEntity() {
for (String code : new String[]{"new-name", "SVC-HALL", "svc-hall "}) {
assertThrows(DException.class, () -> service.edit(1, request(code)));
}
assertEquals("svc-hall", entity.getAppCode());
verify(repository, never()).save(any());
}
@Test
void sameCodeOrOmittedCodeCanEditOtherFields() {
for (String code : new String[]{"svc-hall", null, "", " "}) {
service.edit(1, request(code));
assertEquals("svc-hall", entity.getAppCode());
assertEquals("display-name", entity.getName());
}
}
@Test
void unregisteredCodeMayBeFilledButUniquenessStillApplies() {
entity.setAppCode(null);
when(repository.existsByAppCodeAndIdNot("taken", 1)).thenReturn(true);
assertThrows(DException.class, () -> service.edit(1, request("taken")));
service.edit(1, request("svc-hall"));
assertEquals("svc-hall", entity.getAppCode());
}
@Test
void genericSaveCannotRenameOrClearPersistedCode() {
when(repository.findPersistedAppCodeById(1)).thenReturn("svc-hall");
for (String code : new String[]{"new-name", null, ""}) {
entity.setAppCode(code);
assertThrows(DException.class, () -> service.save(entity));
}
entity.setAppCode("svc-hall");
assertSame(entity, service.save(entity));
}
@Test
void genericSaveAllowsInitialRegistration() {
when(repository.findPersistedAppCodeById(1)).thenReturn(null);
assertSame(entity, service.save(entity));
entity.setId(null);
assertSame(entity, service.save(entity));
}
}
@@ -0,0 +1,138 @@
package com.yunhai.userbase.service.impl;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.context.ApplicationEventPublisher;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* by-code / by-key 查询数据面单测(方案 §5.1 阻塞项防回归)。<br>
* 关键契约:by-key 查的是 app_key 列而非主键 id——160 实测 id ≠ app_key
* (15 行中仅 2 行相等,流程中心 appKey=100009 的主键是 100011,id=100009 是课堂作业),
* 传错会把「流程中心」静默查成「课堂作业」。
*/
class ApplicationInfoLookupTest {
@InjectMocks
private ApplicationInfoService applicationInfoService;
@Mock
private ApplicationInfoRepository applicationInfoRepository;
@Mock
private ApplicationEventPublisher eventPublisher;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
}
private ApplicationInfo flowCenter() {
ApplicationInfo app = new ApplicationInfo();
app.setId(100011);
app.setAppKey(100009);
app.setName("流程中心");
app.setAppCode("flow-center");
return app;
}
@Test
void getByAppKey_按appKey列查询_绝不按主键id() {
ApplicationInfo center = flowCenter();
when(applicationInfoRepository.findByAppKey(100009)).thenReturn(center);
when(applicationInfoRepository.findById(100011)).thenReturn(Optional.of(center));
ApplicationInfo result = applicationInfoService.getByAppKey(100009);
assertSame(center, result);
assertEquals("流程中心", result.getName());
verify(applicationInfoRepository).findByAppKey(100009);
// 防回归核心:主键 id=100009 是「课堂作业」,按 id 查即静默跳错
verify(applicationInfoRepository, never()).findById(100009);
}
@Test
void getByAppKey_查无返回null() {
when(applicationInfoRepository.findByAppKey(999999)).thenReturn(null);
assertNull(applicationInfoService.getByAppKey(999999));
assertNull(applicationInfoService.getByAppKey(null));
}
@Test
void getByAppCode_按技术标识查询() {
ApplicationInfo center = flowCenter();
when(applicationInfoRepository.findByAppCode("flow-center")).thenReturn(center);
assertSame(center, applicationInfoService.getByAppCode("flow-center"));
verify(applicationInfoRepository).findByAppCode("flow-center");
}
@Test
void getByAppCode_空白入参不落库() {
assertNull(applicationInfoService.getByAppCode(null));
assertNull(applicationInfoService.getByAppCode(" "));
verify(applicationInfoRepository, never()).findByAppCode(any());
}
@Test
void edit_空appCode不回写主表_防部分提交清空引用键() {
ApplicationInfo existing = flowCenter();
when(applicationInfoRepository.findById(100011)).thenReturn(Optional.of(existing));
when(applicationInfoRepository.existsByNameAndIdNot("流程中心", 100011)).thenReturn(false);
when(applicationInfoRepository.save(any())).thenReturn(existing);
com.yunhai.userbase.model.application.ApplicationInfoRequest request =
new com.yunhai.userbase.model.application.ApplicationInfoRequest();
request.setName("流程中心");
// 模拟学校覆盖线/部分提交:不回传 appCode/appNature
request.setAppCode(null);
request.setAppNature(null);
applicationInfoService.edit(100011, request);
assertEquals("flow-center", existing.getAppCode(), "引用键不得被 null 覆盖清空");
assertNull(existing.getAppNature(), "性质同样不得被 null 覆盖");
verify(applicationInfoRepository, never()).existsByAppCodeAndIdNot(any(), any());
}
@Test
void listAppCodesByIds_空appCode行跳过_按首现去重() {
ApplicationInfo center = flowCenter();
ApplicationInfo legacy = new ApplicationInfo();
legacy.setId(100012);
legacy.setAppCode(null);
ApplicationInfo notice = new ApplicationInfo();
notice.setId(100013);
notice.setAppCode("notice");
when(applicationInfoRepository.findAllById(any())).thenReturn(
java.util.Arrays.asList(center, legacy, notice, center));
java.util.List<String> codes = applicationInfoService.listAppCodesByIds(
java.util.Arrays.asList(100011, 100012, 100013));
assertEquals(java.util.Arrays.asList("flow-center", "notice"), codes,
"null app_code 跳过(存量历史行),重复去重");
}
@Test
void listAppCodesByIds_空入参返回空不出站() {
assertEquals(java.util.Collections.emptyList(), applicationInfoService.listAppCodesByIds(null));
assertEquals(java.util.Collections.emptyList(), applicationInfoService.listAppCodesByIds(
java.util.Collections.emptyList()));
verify(applicationInfoRepository, never()).findAllById(any());
}
}

Some files were not shown because too many files have changed in this diff Show More