Эх сурвалжийг харах

bump: 发布1.11.0

details see: https://helio.uncarbon.cc/#/i18n/zh-CN/appendix/change-log
Uncarbon 2 жил өмнө
parent
commit
abbf8a14aa
78 өөрчлөгдсөн 1757 нэмэгдсэн , 523 устгасан
  1. 6 15
      README.md
  2. 12 0
      attachments/db/MySQL/upgrade/1.10.0_to_1.11.0.sql
  3. 10 0
      attachments/db/PostgreSQL/upgrade/1.10.0_to_1.11.0.sql
  4. 185 0
      checkstyle-v1.xml
  5. 4 3
      pom.xml
  6. 70 56
      src/main/java/cc/uncarbon/aspect/SysLogAspect.java
  7. 10 8
      src/main/java/cc/uncarbon/config/AsyncConfiguration.java
  8. 48 18
      src/main/java/cc/uncarbon/helper/CaptchaHelper.java
  9. 3 2
      src/main/java/cc/uncarbon/interceptor/AdminSaTokenParseInterceptor.java
  10. 3 1
      src/main/java/cc/uncarbon/interceptor/DefaultSaTokenParseInterceptor.java
  11. 1 1
      src/main/java/cc/uncarbon/module/adminapi/constant/AdminApiConstant.java
  12. 23 0
      src/main/java/cc/uncarbon/module/adminapi/enums/AdminApiErrorEnum.java
  13. 32 0
      src/main/java/cc/uncarbon/module/adminapi/event/KickOutSysUsersEvent.java
  14. 31 0
      src/main/java/cc/uncarbon/module/adminapi/listener/AdminApiEventListener.java
  15. 31 0
      src/main/java/cc/uncarbon/module/adminapi/model/interior/AdminCaptchaContainer.java
  16. 30 0
      src/main/java/cc/uncarbon/module/adminapi/model/response/AdminCaptchaVO.java
  17. 2 4
      src/main/java/cc/uncarbon/module/adminapi/model/response/SelectOptionItemVO.java
  18. 62 64
      src/main/java/cc/uncarbon/module/adminapi/util/AdminStpUtil.java
  19. 10 22
      src/main/java/cc/uncarbon/module/adminapi/web/auth/AdminAuthController.java
  20. 18 37
      src/main/java/cc/uncarbon/module/adminapi/web/common/AdminSelectOptionsController.java
  21. 1 2
      src/main/java/cc/uncarbon/module/adminapi/web/oss/AdminOssUploadDownloadController.java
  22. 1 1
      src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysDataDictController.java
  23. 4 1
      src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysRoleController.java
  24. 16 3
      src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysTenantController.java
  25. 9 0
      src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysUserController.java
  26. 1 1
      src/main/java/cc/uncarbon/module/appapi/constant/AppApiConstant.java
  27. 1 1
      src/main/java/cc/uncarbon/module/oss/constant/OssConstant.java
  28. 1 1
      src/main/java/cc/uncarbon/module/oss/enums/OssErrorEnum.java
  29. 2 2
      src/main/java/cc/uncarbon/module/oss/mapper/OssFileInfoMapper.java
  30. 0 58
      src/main/java/cc/uncarbon/module/oss/model/request/AdminInsertOrUpdateOssFileInfoDTO.java
  31. 2 3
      src/main/java/cc/uncarbon/module/oss/service/OssFileInfoService.java
  32. 12 1
      src/main/java/cc/uncarbon/module/sys/constant/SysConstant.java
  33. 2 2
      src/main/java/cc/uncarbon/module/sys/entity/SysDataDictEntity.java
  34. 17 2
      src/main/java/cc/uncarbon/module/sys/entity/SysRoleEntity.java
  35. 35 2
      src/main/java/cc/uncarbon/module/sys/enums/SysErrorEnum.java
  36. 3 0
      src/main/java/cc/uncarbon/module/sys/extension/impl/DefaultSysLogAspectExtension.java
  37. 15 1
      src/main/java/cc/uncarbon/module/sys/facade/SysTenantFacade.java
  38. 81 14
      src/main/java/cc/uncarbon/module/sys/facade/impl/SysTenantFacadeImpl.java
  39. 11 1
      src/main/java/cc/uncarbon/module/sys/mapper/SysDeptMapper.java
  40. 1 1
      src/main/java/cc/uncarbon/module/sys/mapper/SysRoleMapper.java
  41. 21 0
      src/main/java/cc/uncarbon/module/sys/mapper/SysUserMapper.java
  42. 4 2
      src/main/java/cc/uncarbon/module/sys/mapper/xml/SysUserMapper.xml
  43. 82 0
      src/main/java/cc/uncarbon/module/sys/model/interior/UserDeptContainer.java
  44. 48 0
      src/main/java/cc/uncarbon/module/sys/model/interior/UserRoleContainer.java
  45. 13 4
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysDataDictDTO.java
  46. 4 5
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysDeptDTO.java
  47. 8 5
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysMenuDTO.java
  48. 4 0
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysParamDTO.java
  49. 19 6
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysRoleDTO.java
  50. 20 0
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysUserDTO.java
  51. 12 4
      src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertSysTenantDTO.java
  52. 1 1
      src/main/java/cc/uncarbon/module/sys/model/request/AdminListSysDataDictDTO.java
  53. 2 2
      src/main/java/cc/uncarbon/module/sys/model/request/AdminListSysRoleDTO.java
  54. 12 0
      src/main/java/cc/uncarbon/module/sys/model/request/AdminListSysUserDTO.java
  55. 2 2
      src/main/java/cc/uncarbon/module/sys/model/request/AdminResetSysUserPasswordDTO.java
  56. 2 2
      src/main/java/cc/uncarbon/module/sys/model/request/AdminUpdateCurrentSysUserPasswordDTO.java
  57. 3 4
      src/main/java/cc/uncarbon/module/sys/model/request/AdminUpdateSysTenantDTO.java
  58. 5 5
      src/main/java/cc/uncarbon/module/sys/model/request/SysUserLoginDTO.java
  59. 2 2
      src/main/java/cc/uncarbon/module/sys/model/response/SysDataDictBO.java
  60. 2 2
      src/main/java/cc/uncarbon/module/sys/model/response/SysRoleBO.java
  61. 26 0
      src/main/java/cc/uncarbon/module/sys/model/response/SysTenantKickOutUsersBO.java
  62. 5 4
      src/main/java/cc/uncarbon/module/sys/model/response/SysUserLoginBO.java
  63. 3 2
      src/main/java/cc/uncarbon/module/sys/model/response/SysUserLoginVO.java
  64. 1 2
      src/main/java/cc/uncarbon/module/sys/service/SysDataDictService.java
  65. 79 22
      src/main/java/cc/uncarbon/module/sys/service/SysDeptService.java
  66. 1 2
      src/main/java/cc/uncarbon/module/sys/service/SysLogService.java
  67. 8 15
      src/main/java/cc/uncarbon/module/sys/service/SysMenuService.java
  68. 1 2
      src/main/java/cc/uncarbon/module/sys/service/SysParamService.java
  69. 14 23
      src/main/java/cc/uncarbon/module/sys/service/SysRoleMenuRelationService.java
  70. 224 24
      src/main/java/cc/uncarbon/module/sys/service/SysRoleService.java
  71. 26 14
      src/main/java/cc/uncarbon/module/sys/service/SysTenantService.java
  72. 22 1
      src/main/java/cc/uncarbon/module/sys/service/SysUserDeptRelationService.java
  73. 20 0
      src/main/java/cc/uncarbon/module/sys/service/SysUserRoleRelationService.java
  74. 251 35
      src/main/java/cc/uncarbon/module/sys/service/SysUserService.java
  75. 1 1
      src/main/java/cc/uncarbon/module/sys/util/PwdUtil.java
  76. 2 1
      src/main/resources/application.yml
  77. 1 1
      src/main/resources/banner.txt
  78. 0 0
      多模块版请移步至helio-boot-modular项目.md

+ 6 - 15
README.md

@@ -1,9 +1,11 @@
 # helio-boot
 
 ## 项目介绍
-`helio-boot`基于 Spring Boot 2.7.x,是一款预置SaaS、RBAC能力的单体项目脚手架,适合初学者学习 JavaWeb 开发的良好实践
+基于 Spring Boot 2.7.x,是一款预置SaaS、RBAC能力的单体项目脚手架,适合初学者学习 JavaWeb 开发的良好实践  
+单Maven模块,定位为快速开发脚手架  
+多模块版请移步至[helio-boot-modular](https://github.com/uncarbon97/helio-boot-modular)项目
 
-JDK compatibility: 1.8 - 17
+JDK compatibility: 1.8 - 21
 
 【[前端演示站](https://helio-demo.uncarbon.cc/)】
 【[官方文档](https://helio.uncarbon.cc/)】 
@@ -16,24 +18,13 @@ JDK compatibility: 1.8 - 17
 
 基础支撑构件 [helio-starters](https://github.com/uncarbon97/helio-starters) 已推送至Maven中央仓库,加载时会自动拉取
 
-## 配套后台管理前端模板 && 代码生成器
+## 配套后台管理前端模板 & 代码生成器
 | 项目名                  | 简介                                                                          | Gitee                                                      | GitHub                                                       |
 |----------------------|-----------------------------------------------------------------------------|------------------------------------------------------------|--------------------------------------------------------------|
 | helio-generator      | 可一键生成单体or微服务版的前、后端代码,减少无谓的重复劳动                                              | [Gitee](https://gitee.com/uncarbon97/helio-generator)      | [GitHub](https://github.com/uncarbon97/helio-generator)      |
 | helio-admin-vue-vben | 基于[Vue vben admin](https://github.com/anncwb/vue-vben-admin) 改造适配的前端框架,开箱即用 | [Gitee](https://gitee.com/uncarbon97/helio-admin-vue-vben) | [GitHub](https://github.com/uncarbon97/helio-admin-vue-vben) |
 
-## 代码分支
-| 分支名        | 简介                                                                |
-|------------|-------------------------------------------------------------------|
-| single/1.x | 单Maven模块,定位为快速开发脚手架,适合初学者学习 JavaWeb 开发的良好实践                       | 
-| multi/1.x  | 按职责拆分为多Maven模块,依然是大单体,但命名及用途对标`helio-cloud`;适合多人协作、但不打算使用微服务架构的团队 |
-
-## 演示项目
-| 项目名            | 后端                                                                    | 前端                                                                              | 需导入数据库文件                                                                     |
-|----------------|-----------------------------------------------------------------------|---------------------------------------------------------------------------------|------------------------------------------------------------------------------|
-| library 图书管理系统 | [Gitee](https://gitee.com/uncarbon97/helio-boot/tree/demo%2Flibrary/) | [Gitee](https://gitee.com/uncarbon97/helio-admin-vue-vben/tree/demo%2Flibrary/) | attachments/db/MySQL/helio_boot.sql<br>attachments/db/demo/library_MySQL.sql |
-
-## 目录结构
+## 源码包结构
 ```
 ├─attachments                  附件
 │  └─db                         数据库变更脚本

+ 12 - 0
attachments/db/MySQL/upgrade/1.10.0_to_1.11.0.sql

@@ -0,0 +1,12 @@
+-- v1.11.0 - 「后台角色」中,`title`字段更名为「角色名」,`value`字段更名为「角色编码」
+ALTER TABLE sys_role
+    MODIFY COLUMN title varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色名',
+    MODIFY COLUMN `value` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '角色编码';
+
+-- v1.11.0 - 「数据字典」中,`value`字段更名为「数据值」
+ALTER TABLE sys_data_dict
+    MODIFY COLUMN `value` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '数据值';
+
+-- v1.11.0 - 扩充「后台用户」数据表,「昵称」字段的长度上限为255
+ALTER TABLE sys_user
+    MODIFY COLUMN `nickname` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '昵称';

+ 10 - 0
attachments/db/PostgreSQL/upgrade/1.10.0_to_1.11.0.sql

@@ -0,0 +1,10 @@
+-- v1.11.0 - 「后台角色」中,`title`字段更名为「角色名」,`value`字段更名为「角色编码」
+COMMENT ON COLUMN "sys_role"."title" IS '角色名';
+COMMENT ON COLUMN "sys_role"."value" IS '角色编码';
+
+-- v1.11.0 - 「数据字典」中,`value`字段更名为「数据值」
+COMMENT ON COLUMN "sys_data_dict"."value" IS '数据值';
+
+-- v1.11.0 - 扩充「后台用户」数据表,「昵称」字段的长度上限为255
+ALTER TABLE "sys_user"
+    ALTER COLUMN "nickname" TYPE varchar(255);

+ 185 - 0
checkstyle-v1.xml

@@ -0,0 +1,185 @@
+<?xml version="1.0"?>
+<!DOCTYPE module PUBLIC
+        "-//Puppy Crawl//DTD Check Configuration 1.3//EN"
+        "http://www.puppycrawl.com/dtds/configuration_1_3.dtd">
+
+<module name="Checker">
+
+    <!-- 文件长度不超过2000行 -->
+    <module name="FileLength">
+        <property name="max" value="2000"/>
+    </module>
+
+    <!-- 每个java文件一个语法树 -->
+    <module name="TreeWalker">
+        <!-- import检查-->
+        <!-- 检查是否从非法的包中导入了类 -->
+        <module name="IllegalImport"/>
+        <!-- 检查是否导入了多余的包 -->
+        <module name="RedundantImport"/>
+        <!-- 没用的import检查,比如:1.没有被用到 2.重复的 3.import java.lang的 4.import 与该类在同一个package的 -->
+        <module name="UnusedImports" />
+
+        <!-- 注释检查 -->
+        <!-- 检查构造函数的javadoc -->
+        <!--
+        <module name="JavadocType">
+            <property name="allowUnknownTags" value="true"/>
+            <message key="javadoc.missing" value="类注释:缺少Javadoc注释。"/>
+        </module>
+        -->
+
+        <!-- 命名检查 -->
+        <!-- 局部的final变量,包括catch中的参数的检查 -->
+        <module name="LocalFinalVariableName" />
+        <!-- 局部的非final型的变量,包括catch中的参数的检查 -->
+        <module name="LocalVariableName" />
+        <!-- 包名的检查(只允许小写字母),默认^[a-z]+(\.[a-zA-Z_][a-zA-Z_0-9_]*)*$ -->
+        <module name="PackageName">
+            <property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$" />
+            <message key="name.invalidPattern" value="包名 ''{0}'' 要符合 ''{1}''格式."/>
+        </module>
+        <!-- 仅仅是static型的变量(不包括static final型)的检查 -->
+        <module name="StaticVariableName" />
+        <!-- Class或Interface名检查,默认^[A-Z][a-zA-Z0-9]*$-->
+        <module name="TypeName">
+            <property name="severity" value="warning"/>
+            <message key="name.invalidPattern" value="名称 ''{0}'' 要符合 ''{1}''格式."/>
+        </module>
+        <!-- 非static型变量的检查 -->
+        <module name="MemberName" />
+        <!-- 方法名的检查 -->
+        <module name="MethodName" />
+        <!-- 方法的参数名 -->
+        <module name="ParameterName " />
+        <!-- 常量名的检查(只允许大写),默认^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$ -->
+        <module name="ConstantName" />
+
+        <!-- 定义检查 -->
+        <!-- 检查数组类型定义的样式 -->
+        <module name="ArrayTypeStyle"/>
+        <!-- 检查long型定义是否有大写的“L” -->
+        <module name="UpperEll"/>
+
+        <!-- 长度检查 -->
+        <!-- 每行不超过200个字符 -->
+        <module name="LineLength">
+            <property name="max" value="200" />
+        </module>
+        <!-- 方法不超过120行 -->
+        <module name="MethodLength">
+            <property name="tokens" value="METHOD_DEF" />
+            <property name="max" value="120" />
+        </module>
+        <!-- 方法的参数个数不超过5个。 并且不对构造方法进行检查-->
+        <module name="ParameterNumber">
+            <property name="max" value="5" />
+            <property name="ignoreOverriddenMethods" value="true"/>
+            <property name="tokens" value="METHOD_DEF" />
+        </module>
+
+        <!-- 空格检查-->
+        <!-- 方法名后跟左圆括号"(" -->
+        <module name="MethodParamPad" />
+        <!-- 在类型转换时,不允许左圆括号右边有空格,也不允许与右圆括号左边有空格 -->
+        <module name="TypecastParenPad" />
+        <!-- 检查在某个特定关键字之后应保留空格 -->
+        <module name="NoWhitespaceAfter"/>
+        <!-- 检查在某个特定关键字之前应保留空格 -->
+        <module name="NoWhitespaceBefore"/>
+        <!-- 操作符换行策略检查 -->
+        <module name="OperatorWrap"/>
+        <!-- 圆括号空白 -->
+        <module name="ParenPad"/>
+        <!-- 检查分隔符是否在空白之后 -->
+        <module name="WhitespaceAfter"/>
+        <!-- 检查分隔符周围是否有空白 -->
+        <module name="WhitespaceAround"/>
+
+        <!-- 修饰符检查 -->
+        <!-- 检查修饰符的顺序是否遵照java语言规范,默认public、protected、private、abstract、static、final、transient、volatile、synchronized、native、strictfp -->
+        <module name="ModifierOrder"/>
+        <!-- 检查接口和annotation中是否有多余修饰符,如接口方法不必使用public -->
+        <module name="RedundantModifier"/>
+
+        <!-- 代码块检查 -->
+        <!-- 检查是否有嵌套代码块 -->
+        <module name="AvoidNestedBlocks"/>
+        <!-- 检查是否有空代码块 -->
+        <module name="EmptyBlock"/>
+        <!-- 检查左大括号位置 -->
+        <module name="LeftCurly"/>
+        <!-- 检查代码块是否缺失{} -->
+        <module name="NeedBraces"/>
+        <!-- 检查右大括号位置 -->
+        <module name="RightCurly"/>
+
+        <!-- 代码检查 -->
+        <!-- 检查空的代码段 -->
+        <module name="EmptyStatement"/>
+        <!-- 检查在重写了equals方法后是否重写了hashCode方法 -->
+        <module name="EqualsHashCode"/>
+        <!-- 检查局部变量或参数是否隐藏了类中的变量 -->
+        <module name="HiddenField">
+            <property name="tokens" value="VARIABLE_DEF"/>
+        </module>
+        <!-- 检查子表达式中是否有赋值操作 -->
+        <module name="InnerAssignment"/>
+        <!-- 检查switch语句是否有default -->
+        <module name="MissingSwitchDefault"/>
+        <!-- 检查是否有过度复杂的布尔表达式 -->
+        <module name="SimplifyBooleanExpression"/>
+        <!-- 检查是否有过于复杂的布尔返回代码段 -->
+        <module name="SimplifyBooleanReturn"/>
+
+        <!-- 类设计检查 -->
+        <!-- 检查类是否为扩展设计l -->
+        <!-- 检查只有private构造函数的类是否声明为final -->
+        <module name="FinalClass"/>
+        <!-- 检查接口是否仅定义类型 -->
+        <module name="InterfaceIsType"/>
+        <!-- 检查类成员的可见度 检查类成员的可见性。只有static final 成员是public的
+        除非在本检查的protectedAllowed和packagedAllowed属性中进行了设置-->
+        <module name="VisibilityModifier">
+            <property name="packageAllowed" value="true"/>
+            <property name="protectedAllowed" value="true"/>
+        </module>
+
+        <!-- 语法 -->
+        <!-- String的比较不能用!= 和 == -->
+        <module name="StringLiteralEquality"/>
+        <!-- 限制for循环最多嵌套2层 -->
+        <module name="NestedForDepth">
+            <property name="max" value="2"/>
+        </module>
+        <!-- if最多嵌套3层 -->
+        <module name="NestedIfDepth">
+            <property name="max" value="3"/>
+        </module>
+        <!-- 检查未被注释的main方法,排除以Application、Test结尾命名的类 -->
+        <module name="UncommentedMain">
+            <property name="excludedClasses" value=".*[Application,Test]$"/>
+        </module>
+        <!-- 禁止使用System.out.println -->
+        <module name="Regexp">
+            <property name="format" value="System\.out\.println"/>
+            <property name="illegalPattern" value="true"/>
+        </module>
+        <!-- return个数 5个-->
+        <module name="ReturnCount">
+            <property name="max" value="5"/>
+        </module>
+        <!--try catch 异常处理数量 3-->
+        <module name="NestedTryDepth ">
+            <property name="max" value="3"/>
+        </module>
+        <!-- clone方法必须调用了super.clone() -->
+        <module name="SuperClone" />
+        <!-- finalize 必须调用了super.finalize() -->
+        <module name="SuperFinalize" />
+
+
+    </module>
+</module>
+<!-- https://blog.csdn.net/liuwei0376/article/details/123180920 -->
+<!-- 依赖checkstyle 8.14版本-->

+ 4 - 3
pom.xml

@@ -8,17 +8,18 @@
     <parent>
         <groupId>cc.uncarbon.framework</groupId>
         <artifactId>helio-starters</artifactId>
-        <version>1.10.0</version>
+        <version>1.11.0</version>
     </parent>
 
     <artifactId>helio-boot</artifactId>
+    <description>Helio 单模块版单体脚手架</description>
     <!-- 制品版本号,建议每次打包发布时都调整以作区分 -->
-    <version>1.10.0</version>
+    <version>1.11.0</version>
 
     <properties>
         <!-- 这里写其他第三方依赖 -->
         <!-- 文件上传 -->
-        <x-file-storage.version>2.0.0</x-file-storage.version>
+        <x-file-storage.version>2.1.0</x-file-storage.version>
         <minio.version>8.4.3</minio.version>
     </properties>
 

+ 70 - 56
src/main/java/cc/uncarbon/aspect/SysLogAspect.java

@@ -97,13 +97,13 @@ public class SysLogAspect {
         HttpServletRequest request = SpringMVCUtil.getRequest();
         AspectContext aspectContext = new AspectContext(request);
 
-        if (!annotation.syncSave()) {
+        if (annotation.syncSave()) {
+            // 同步保存
+            saveSysLog(joinPoint, annotation, aspectContext, null, ret);
+        } else {
             // 异步保存
             saveSysLogAsync(joinPoint, annotation, aspectContext, null, ret);
-            return;
         }
-
-        saveSysLog(joinPoint, annotation, aspectContext, null, ret);
     }
 
     /**
@@ -119,13 +119,13 @@ public class SysLogAspect {
         HttpServletRequest request = SpringMVCUtil.getRequest();
         AspectContext aspectContext = new AspectContext(request);
 
-        if (!annotation.syncSave()) {
+        if (annotation.syncSave()) {
+            // 同步保存
+            saveSysLog(joinPoint, annotation, aspectContext, e, null);
+        } else {
             // 异步保存
             saveSysLogAsync(joinPoint, annotation, aspectContext, e, null);
-            return;
         }
-
-        saveSysLog(joinPoint, annotation, aspectContext, e, null);
     }
 
     /**
@@ -136,7 +136,8 @@ public class SysLogAspect {
      * @param e 异常实例,可以为null
      * @param ret 返回值,可以为null
      */
-    private void saveSysLog(final JoinPoint joinPoint, SysLog annotation, final AspectContext aspectContext, final Throwable e, Object ret) {
+    private void saveSysLog(final JoinPoint joinPoint, SysLog annotation, final AspectContext aspectContext,
+                            final Throwable e, Object ret) {
         try {
             // 指定本线程用户态
             UserContextHolder.setUserContext(aspectContext.getUserContext());
@@ -149,42 +150,8 @@ public class SysLogAspect {
                 extensionInstance = ReflectUtil.newInstance(extensionClazz);
             }
 
-            AdminInsertSysLogDTO dto = new AdminInsertSysLogDTO()
-                    // 记录操作人
-                    .setUserId(UserContextHolder.getUserId())
-                    .setUsername(UserContextHolder.getUserName())
-                    // 记录请求方法
-                    .setMethod(CharSequenceUtil.builder(
-                            joinPoint.getTarget().getClass().getName(),
-                            "#",
-                            joinPoint.getSignature().getName()
-                    ).toString())
-                    // 记录操作内容
-                    .setOperation(annotation.value())
-                    .setIp(aspectContext.getClientIP())
-                    // 默认置为成功
-                    .setStatus(SysLogStatusEnum.SUCCESS)
-                    .setUserAgent(aspectContext.getUserAgent());
-
-            // 记录请求参数
-            Map<Object, Object> afterMasked = new LinkedHashMap<>(32, 1);
-            String params = Arrays.stream(joinPoint.getArgs()).map(
-                    item -> {
-                        if (ClassUtil.isBasicType(item.getClass())) {
-                            // 基元类型 OR 其包装类型,且拿不到参数名,保存在DB时保持原样
-                            return StrUtil.toStringOrNull(item);
-                        }
-
-                        // 先去除敏感字段后再入库
-                        afterMasked.clear();
-                        BeanUtil.copyProperties(item, afterMasked, MASKING_COPY_OPTIONS);
-                        return JSONUtil.toJsonStr(afterMasked, TO_JSON_STR_JSON_CONFIG);
-                    }
-            ).collect(Collectors.joining(StrPool.LF));
-            if (CharSequenceUtil.length(params) > MAX_STRING_SAVE_LENGTH) {
-                params = CharSequenceUtil.subPre(params, MAX_STRING_SAVE_LENGTH);
-            }
-            dto.setParams(params);
+            AdminInsertSysLogDTO dto = buildInsertDTO(joinPoint, annotation, aspectContext);
+            setParamInDTO(joinPoint, dto);
 
             if (e != null) {
                 // 异常不为空,置状态为失败
@@ -221,16 +188,7 @@ public class SysLogAspect {
      */
 
     /**
-     * 异步保存系统日志
-     */
-    private void saveSysLogAsync(final JoinPoint joinPoint, SysLog annotation, final AspectContext aspectContext, final Throwable e, Object ret) {
-        taskExecutor.submit(
-                () -> this.saveSysLog(joinPoint, annotation, aspectContext, e, ret)
-        );
-    }
-
-    /**
-     * 本切面上下文
+     * 本切面上下文,有点像对单次请求做个快照,用来缓解:servlet容器线程复用机制导致HttpServletRequest对象实例丢失的问题
      * 内部使用
      */
     @Getter
@@ -254,7 +212,7 @@ public class SysLogAspect {
         /**
          * 从 HTTP Request 新建
          */
-        public AspectContext(HttpServletRequest request) {
+        AspectContext(HttpServletRequest request) {
             this.userContext = UserContextHolder.getUserContext();
             /*
             记录IP地址
@@ -271,4 +229,60 @@ public class SysLogAspect {
         }
     }
 
+    /**
+     * 异步保存系统日志
+     */
+    private void saveSysLogAsync(final JoinPoint joinPoint, SysLog annotation, final AspectContext aspectContext,
+                                 final Throwable e, Object ret) {
+        taskExecutor.submit(
+                () -> this.saveSysLog(joinPoint, annotation, aspectContext, e, ret)
+        );
+    }
+
+    /**
+     * 构造新增DTO
+     */
+    private static AdminInsertSysLogDTO buildInsertDTO(JoinPoint joinPoint, SysLog annotation, AspectContext aspectContext) {
+        return new AdminInsertSysLogDTO()
+                // 记录操作人
+                .setUserId(UserContextHolder.getUserId())
+                .setUsername(UserContextHolder.getUserName())
+                // 记录请求方法
+                .setMethod(CharSequenceUtil.builder(
+                        joinPoint.getTarget().getClass().getName(),
+                        "#",
+                        joinPoint.getSignature().getName()
+                ).toString())
+                // 记录操作内容
+                .setOperation(annotation.value())
+                .setIp(aspectContext.getClientIP())
+                // 默认置为成功
+                .setStatus(SysLogStatusEnum.SUCCESS)
+                .setUserAgent(aspectContext.getUserAgent());
+    }
+
+    /**
+     * 记录请求参数,设置dto的param字段
+     */
+    private static void setParamInDTO(JoinPoint joinPoint, AdminInsertSysLogDTO dto) {
+        Map<Object, Object> afterMasked = new LinkedHashMap<>(32, 1);
+        String params = Arrays.stream(joinPoint.getArgs()).map(
+                item -> {
+                    if (ClassUtil.isBasicType(item.getClass())) {
+                        // 基元类型 OR 其包装类型,且拿不到参数名,保存在DB时保持原样
+                        return StrUtil.toStringOrNull(item);
+                    }
+
+                    // 先去除敏感字段后再入库
+                    afterMasked.clear();
+                    BeanUtil.copyProperties(item, afterMasked, MASKING_COPY_OPTIONS);
+                    return JSONUtil.toJsonStr(afterMasked, TO_JSON_STR_JSON_CONFIG);
+                }
+        ).collect(Collectors.joining(StrPool.LF));
+        if (CharSequenceUtil.length(params) > MAX_STRING_SAVE_LENGTH) {
+            params = CharSequenceUtil.subPre(params, MAX_STRING_SAVE_LENGTH);
+        }
+        dto.setParams(params);
+    }
+
 }

+ 10 - 8
src/main/java/cc/uncarbon/config/AsyncConfiguration.java

@@ -36,14 +36,16 @@ public class AsyncConfiguration implements AsyncConfigurer {
     public ThreadPoolTaskExecutor taskExecutor() {
         final String threadNamePrefix = "taskExecutor-";
 
-        log.debug("[异步任务线程池] 创建默认线程池【taskExecutor】,该线程池参数可通过 spring.task.execution 调节 >> " +
-                        "corePoolSize核心线程池大小={}, maxPoolSize最大线程数={}, queueCapacity队列容量={}" +
-                        ", rejectedExecutionHandler拒绝策略={}",
-                taskExecutionProperties.getPool().getCoreSize(),
-                taskExecutionProperties.getPool().getMaxSize(),
-                taskExecutionProperties.getPool().getQueueCapacity(),
-                "CallerRunsPolicy"
-                );
+        if (log.isDebugEnabled()) {
+            log.debug("[异步任务线程池] 创建默认线程池【taskExecutor】,该线程池参数可通过 spring.task.execution 调节 >> "
+                            + "corePoolSize核心线程池大小={}, maxPoolSize最大线程数={}, queueCapacity队列容量={}"
+                            + ", rejectedExecutionHandler拒绝策略={}",
+                    taskExecutionProperties.getPool().getCoreSize(),
+                    taskExecutionProperties.getPool().getMaxSize(),
+                    taskExecutionProperties.getPool().getQueueCapacity(),
+                    "CallerRunsPolicy"
+            );
+        }
 
         ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
         // 核心线程池大小,默认 8

+ 48 - 18
src/main/java/cc/uncarbon/helper/CaptchaHelper.java

@@ -1,12 +1,19 @@
 package cc.uncarbon.helper;
 
+import cc.uncarbon.framework.core.exception.BusinessException;
+import cc.uncarbon.module.adminapi.enums.AdminApiErrorEnum;
+import cc.uncarbon.module.adminapi.model.interior.AdminCaptchaContainer;
 import cn.hutool.captcha.CaptchaUtil;
 import cn.hutool.captcha.ShearCaptcha;
+import cn.hutool.core.date.LocalDateTimeUtil;
+import cn.hutool.core.lang.UUID;
 import cn.hutool.core.text.CharSequenceUtil;
 import lombok.RequiredArgsConstructor;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Component;
 
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -22,43 +29,66 @@ public class CaptchaHelper {
 
     private static final String CACHE_KEY_CAPTCHA_ANSWER = "Authorization:captcha:uuid_%s";
 
+    /**
+     * 验证码答案长度
+     */
+    private static final int CAPTCHA_ANSWER_LENGTH = 4;
 
     /**
-     * 生成一个验证码图片对象
-     *
-     * @param uuid UUID
-     * @return ShearCaptcha
+     * 生成一个验证码
      */
-    public ShearCaptcha generate(String uuid) {
+    public AdminCaptchaContainer generate() {
+        // redis预占位;随机10个UUID,应该有个能成的吧……
+        UUID uuid = UUID.randomUUID();
+        String captchaCacheKey = null;
+        Boolean stubFlag = Boolean.FALSE;
+        for (int count = 0; count < 10; count++) {
+            captchaCacheKey = String.format(CACHE_KEY_CAPTCHA_ANSWER, uuid.toString(true));
+            stubFlag = stringRedisTemplate.opsForValue().setIfAbsent(captchaCacheKey, CharSequenceUtil.EMPTY);
+            if (Boolean.TRUE.equals(stubFlag)) {
+                break;
+            }
+            uuid = UUID.randomUUID();
+        }
+        if (!Boolean.TRUE.equals(stubFlag)) {
+            throw new BusinessException(AdminApiErrorEnum.CAPTCHA_GENERATE_FAILED);
+        }
+
         // 定义图形验证码的长、宽、验证码字符数、干扰线宽度
-        ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(196, 50, 4, 4);
+        ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(196, 50, CAPTCHA_ANSWER_LENGTH, 4);
 
-        // 将验证码答案保存至 redis , 有效期5分钟
-        stringRedisTemplate.opsForValue().set(String.format(CACHE_KEY_CAPTCHA_ANSWER, uuid), captcha.getCode(), 300, TimeUnit.SECONDS);
+        // 将验证码答案保存至 redis, 有效期5分钟
+        stringRedisTemplate.opsForValue().set(captchaCacheKey, captcha.getCode(), 300, TimeUnit.SECONDS);
+        LocalDateTime expiredAt = LocalDateTimeUtil.offset(LocalDateTimeUtil.now(), 300, ChronoUnit.SECONDS);
 
-        return captcha;
+        return new AdminCaptchaContainer(captcha, uuid.toString(true), expiredAt);
     }
 
     /**
-     * 校验验证码是否输入正确
+     * 验验证码是否输入正确
      *
-     * @param uuid          UUID
-     * @param captchaAnswer 验证码答案
-     * @param removeWhenEquals 匹配时自动移除缓存键
+     * @param uuid             验证码唯一标识(UUID)
+     * @param captchaAnswer    验证码答案
      * @return 是否正确
      */
-    public boolean validate(String uuid, String captchaAnswer, boolean removeWhenEquals) {
+    public boolean validate(String uuid, String captchaAnswer) {
         if (CharSequenceUtil.hasBlank(uuid, captchaAnswer)) {
             return false;
         }
 
         String cacheKey = String.format(CACHE_KEY_CAPTCHA_ANSWER, uuid);
-        String answerInRedis = stringRedisTemplate.opsForValue().get(cacheKey);
-        boolean equals = CharSequenceUtil.equalsIgnoreCase(answerInRedis, captchaAnswer);
-        if (equals && removeWhenEquals) {
+        boolean equals;
+        try {
+            if (CharSequenceUtil.length(captchaAnswer) != CAPTCHA_ANSWER_LENGTH) {
+                // 长度不同
+                return false;
+            }
+
+            String answerInRedis = stringRedisTemplate.opsForValue().get(cacheKey);
+            equals = CharSequenceUtil.equalsIgnoreCase(answerInRedis, captchaAnswer);
+        } finally {
             stringRedisTemplate.delete(cacheKey);
         }
-
         return equals;
     }
 }

+ 3 - 2
src/main/java/cc/uncarbon/interceptor/AdminSaTokenParseInterceptor.java

@@ -35,8 +35,9 @@ public class AdminSaTokenParseInterceptor implements AsyncHandlerInterceptor {
         // SA-Token 会自动从请求头中解析 token,所以这里可以直接拿到对应 session,从而取出业务字段
         if (AdminStpUtil.isLogin()) {
             setContextsFromSaSession(AdminStpUtil.getSession(), request);
-
-            log.debug("[SA-Token][Admin] 从请求头解析出用户上下文 >> {}", UserContextHolder.getUserContext());
+            if (log.isDebugEnabled()) {
+                log.debug("[SA-Token][Admin] 从请求头解析出用户上下文 >> {}", UserContextHolder.getUserContext());
+            }
         } else {
             UserContextHolder.clear();
             TenantContextHolder.clear();

+ 3 - 1
src/main/java/cc/uncarbon/interceptor/DefaultSaTokenParseInterceptor.java

@@ -32,7 +32,9 @@ public class DefaultSaTokenParseInterceptor implements AsyncHandlerInterceptor {
         // SA-Token 会自动从请求头中解析 token,所以这里可以直接拿到对应 session,从而取出业务字段
         if (StpUtil.isLogin()) {
             setContextsFromSaSession(StpUtil.getSession(), request);
-            log.debug("[SA-Token] 从请求头解析出用户上下文 >> {}", UserContextHolder.getUserContext());
+            if (log.isDebugEnabled()) {
+                log.debug("[SA-Token] 从请求头解析出用户上下文 >> {}", UserContextHolder.getUserContext());
+            }
         } else {
             UserContextHolder.clear();
             TenantContextHolder.clear();

+ 1 - 1
src/main/java/cc/uncarbon/module/adminapi/constant/AdminApiConstant.java

@@ -4,7 +4,7 @@ package cc.uncarbon.module.adminapi.constant;
 /**
  * 后台管理接口常量
  */
-public class AdminApiConstant {
+public final class AdminApiConstant {
     private AdminApiConstant() {
     }
 

+ 23 - 0
src/main/java/cc/uncarbon/module/adminapi/enums/AdminApiErrorEnum.java

@@ -0,0 +1,23 @@
+package cc.uncarbon.module.adminapi.enums;
+
+import cc.uncarbon.framework.core.enums.HelioBaseEnum;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+
+/**
+ * admin-api模块错误枚举类
+ */
+@AllArgsConstructor
+@Getter
+public enum AdminApiErrorEnum implements HelioBaseEnum<Integer> {
+
+    CAPTCHA_GENERATE_FAILED(500, "验证码生成失败,请稍后再试"),
+
+    CAPTCHA_VALIDATE_FAILED(400, "验证码不正确,请重新输入"),
+
+    ;
+    private final Integer value;
+    private final String label;
+
+}

+ 32 - 0
src/main/java/cc/uncarbon/module/adminapi/event/KickOutSysUsersEvent.java

@@ -0,0 +1,32 @@
+package cc.uncarbon.module.adminapi.event;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.ApplicationEvent;
+
+import java.util.Collection;
+
+/**
+ * 强制登出后台用户事件
+ */
+@Getter
+public final class KickOutSysUsersEvent extends ApplicationEvent {
+
+    private final transient EventData data;
+
+    public KickOutSysUsersEvent(EventData data) {
+        super(data);
+        this.data = data;
+    }
+
+    @Getter
+    @RequiredArgsConstructor
+    public static final class EventData {
+
+        /**
+         * 需要被强制登出的后台用户IDs
+         */
+        private final Collection<Long> sysUserIds;
+
+    }
+}

+ 31 - 0
src/main/java/cc/uncarbon/module/adminapi/listener/AdminApiEventListener.java

@@ -0,0 +1,31 @@
+package cc.uncarbon.module.adminapi.listener;
+
+import cc.uncarbon.module.adminapi.event.KickOutSysUsersEvent;
+import cc.uncarbon.module.adminapi.util.AdminStpUtil;
+import cn.hutool.core.collection.CollUtil;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.event.EventListener;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.stereotype.Component;
+
+import java.util.Collection;
+
+/**
+ * admin-api模块事件监听器
+ */
+@Component
+@RequiredArgsConstructor
+public class AdminApiEventListener {
+
+    private final ThreadPoolTaskExecutor taskExecutor;
+
+
+    @EventListener(value = KickOutSysUsersEvent.class)
+    public void handleKickOutSysUsersEvent(KickOutSysUsersEvent event) {
+        Collection<Long> sysUserIds = event.getData().getSysUserIds();
+        if (CollUtil.isNotEmpty(sysUserIds)) {
+            // 异步强制登出;同一时间大量登出,会操作大量Redis键,可能存在缓存雪崩的风险
+            taskExecutor.submit(() -> sysUserIds.forEach(AdminStpUtil::kickout));
+        }
+    }
+}

+ 31 - 0
src/main/java/cc/uncarbon/module/adminapi/model/interior/AdminCaptchaContainer.java

@@ -0,0 +1,31 @@
+package cc.uncarbon.module.adminapi.model.interior;
+
+import cn.hutool.captcha.AbstractCaptcha;
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+import java.time.LocalDateTime;
+
+/**
+ * 后台管理-验证码容器
+ */
+@RequiredArgsConstructor
+@Getter
+public class AdminCaptchaContainer {
+
+    /**
+     * 验证码图片对象
+     */
+    private final AbstractCaptcha image;
+
+    /**
+     * 验证码唯一标识(UUID)
+     */
+    private final String uuid;
+
+    /**
+     * 验证码失效时刻
+     */
+    private final LocalDateTime expiredAt;
+
+}

+ 30 - 0
src/main/java/cc/uncarbon/module/adminapi/model/response/AdminCaptchaVO.java

@@ -0,0 +1,30 @@
+package cc.uncarbon.module.adminapi.model.response;
+
+import cc.uncarbon.module.adminapi.model.interior.AdminCaptchaContainer;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Getter;
+
+import java.time.LocalDateTime;
+
+/**
+ * 后台管理-验证码 VO
+ */
+@Getter
+public class AdminCaptchaVO {
+
+    @ApiModelProperty(value = "验证码图片Base64")
+    private final String captchaImage;
+
+    @ApiModelProperty(value = "验证码唯一标识")
+    private final String captchaId;
+
+    @ApiModelProperty(value = "验证码失效时刻")
+    private final LocalDateTime expiredAt;
+
+
+    public AdminCaptchaVO(AdminCaptchaContainer source) {
+        this.captchaImage = source.getImage().getImageBase64Data();
+        this.captchaId = source.getUuid();
+        this.expiredAt = source.getExpiredAt();
+    }
+}

+ 2 - 4
src/main/java/cc/uncarbon/module/adminapi/model/response/SelectOptionItemVO.java

@@ -62,15 +62,13 @@ public class SelectOptionItemVO implements Serializable {
 
     /*
     ----------------------------------------------------------------
-                        其他业务字段都写在这里
+                        自定义业务字段都写在这里
                         都要标记释义、用处、新增时版本号
                         免得每个人各取一个名,不统一
     ----------------------------------------------------------------
      */
 
-    @ApiModelProperty(value = "(演示字段;可删除)文本字段1")
-    @Setter
-    private String demoString1;
+
 
     /*
     ----------------------------------------------------------------

+ 62 - 64
src/main/java/cc/uncarbon/module/adminapi/util/AdminStpUtil.java

@@ -14,13 +14,14 @@ import java.util.List;
 /**
  * 后台管理专用 StpUtil
  * 文档见 SA-Token -> 多账户认证:https://sa-token.cc/doc.html#/up/many-account
- * @see cn.dev33.satoken.stp.StpUtil
  *
  * @author Uncarbon
+ * @see cn.dev33.satoken.stp.StpUtil
  */
-public class AdminStpUtil {
+public final class AdminStpUtil {
 
-    private AdminStpUtil() {}
+    private AdminStpUtil() {
+    }
 
     /**
      * 多账号体系下的类型标识
@@ -37,7 +38,7 @@ public class AdminStpUtil {
      *
      * @return /
      */
-    public static String getLoginType(){
+    public static String getLoginType() {
         return stpLogic.getLoginType();
     }
 
@@ -88,17 +89,17 @@ public class AdminStpUtil {
      *
      * @param tokenValue token 值
      */
-    public static void setTokenValue(String tokenValue){
+    public static void setTokenValue(String tokenValue) {
         stpLogic.setTokenValue(tokenValue);
     }
 
     /**
      * 在当前会话写入指定 token 值
      *
-     * @param tokenValue token 值
+     * @param tokenValue    token 值
      * @param cookieTimeout Cookie存活时间(秒)
      */
-    public static void setTokenValue(String tokenValue, int cookieTimeout){
+    public static void setTokenValue(String tokenValue, int cookieTimeout) {
         stpLogic.setTokenValue(tokenValue, cookieTimeout);
     }
 
@@ -108,7 +109,7 @@ public class AdminStpUtil {
      * @param tokenValue token 值
      * @param loginModel 登录参数
      */
-    public static void setTokenValue(String tokenValue, SaLoginModel loginModel){
+    public static void setTokenValue(String tokenValue, SaLoginModel loginModel) {
         stpLogic.setTokenValue(tokenValue, loginModel);
     }
 
@@ -126,7 +127,7 @@ public class AdminStpUtil {
      *
      * @return /
      */
-    public static String getTokenValueNotCut(){
+    public static String getTokenValueNotCut() {
         return stpLogic.getTokenValueNotCut();
     }
 
@@ -156,7 +157,7 @@ public class AdminStpUtil {
     /**
      * 会话登录,并指定登录设备类型
      *
-     * @param id 账号id,建议的类型:(long | int | String)
+     * @param id     账号id,建议的类型:(long | int | String)
      * @param device 设备类型
      */
     public static void login(Object id, String device) {
@@ -166,7 +167,7 @@ public class AdminStpUtil {
     /**
      * 会话登录,并指定是否 [记住我]
      *
-     * @param id 账号id,建议的类型:(long | int | String)
+     * @param id              账号id,建议的类型:(long | int | String)
      * @param isLastingCookie 是否为持久Cookie,值为 true 时记住我,值为 false 时关闭浏览器需要重新登录
      */
     public static void login(Object id, boolean isLastingCookie) {
@@ -186,7 +187,7 @@ public class AdminStpUtil {
     /**
      * 会话登录,并指定所有登录参数 Model
      *
-     * @param id 账号id,建议的类型:(long | int | String)
+     * @param id         账号id,建议的类型:(long | int | String)
      * @param loginModel 此次登录的参数Model
      */
     public static void login(Object id, SaLoginModel loginModel) {
@@ -206,7 +207,7 @@ public class AdminStpUtil {
     /**
      * 创建指定账号 id 的登录会话数据
      *
-     * @param id 账号id,建议的类型:(long | int | String)
+     * @param id         账号id,建议的类型:(long | int | String)
      * @param loginModel 此次登录的参数Model
      * @return 返回会话令牌
      */
@@ -236,7 +237,7 @@ public class AdminStpUtil {
      * 会话注销,根据账号id 和 设备类型
      *
      * @param loginId 账号id
-     * @param device 设备类型 (填 null 代表注销该账号的所有设备类型)
+     * @param device  设备类型 (填 null 代表注销该账号的所有设备类型)
      */
     public static void logout(Object loginId, String device) {
         stpLogic.logout(loginId, device);
@@ -266,7 +267,7 @@ public class AdminStpUtil {
      * <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-5 </p>
      *
      * @param loginId 账号id
-     * @param device 设备类型 (填 null 代表踢出该账号的所有设备类型)
+     * @param device  设备类型 (填 null 代表踢出该账号的所有设备类型)
      */
     public static void kickout(Object loginId, String device) {
         stpLogic.kickout(loginId, device);
@@ -287,7 +288,7 @@ public class AdminStpUtil {
      * <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-4 </p>
      *
      * @param loginId 账号id
-     * @param device 设备类型 (填 null 代表顶替该账号的所有设备类型)
+     * @param device  设备类型 (填 null 代表顶替该账号的所有设备类型)
      */
     public static void replaced(Object loginId, String device) {
         stpLogic.replaced(loginId, device);
@@ -332,7 +333,7 @@ public class AdminStpUtil {
     /**
      * 获取当前会话账号id, 如果未登录,则返回默认值
      *
-     * @param <T> 返回类型
+     * @param <T>          返回类型
      * @param defaultValue 默认值
      * @return 登录id
      */
@@ -400,7 +401,7 @@ public class AdminStpUtil {
      * 获取指定 Token 的扩展信息(此函数只在jwt模式下生效)
      *
      * @param tokenValue 指定的 Token 值
-     * @param key 键值
+     * @param key        键值
      * @return 对应的扩展数据
      */
     public static Object getExtra(String tokenValue, String key) {
@@ -413,7 +414,7 @@ public class AdminStpUtil {
     /**
      * 获取指定账号 id 的 Account-Session, 如果该 SaSession 尚未创建,isCreate=是否新建并返回
      *
-     * @param loginId 账号id
+     * @param loginId  账号id
      * @param isCreate 是否新建
      * @return SaSession 对象
      */
@@ -497,8 +498,8 @@ public class AdminStpUtil {
     /**
      * 续签当前 token:(将 [最后操作时间] 更新为当前时间戳)
      * <h2>
-     * 		请注意: 即使 token 已被冻结 也可续签成功,
-     * 		如果此场景下需要提示续签失败,可在此之前调用 checkActiveTimeout() 强制检查是否冻结即可
+     * 请注意: 即使 token 已被冻结 也可续签成功,
+     * 如果此场景下需要提示续签失败,可在此之前调用 checkActiveTimeout() 强制检查是否冻结即可
      * </h2>
      */
     public static void updateLastActiveToNow() {
@@ -574,7 +575,7 @@ public class AdminStpUtil {
      * 对指定 token 的 timeout 值进行续期
      *
      * @param tokenValue 指定 token
-     * @param timeout 要修改成为的有效时间 (单位: 秒,填 -1 代表要续为永久有效)
+     * @param timeout    要修改成为的有效时间 (单位: 秒,填 -1 代表要续为永久有效)
      */
     public static void renewTimeout(String tokenValue, long timeout) {
         stpLogic.renewTimeout(tokenValue, timeout);
@@ -616,7 +617,7 @@ public class AdminStpUtil {
      * 判断:指定账号是否含有指定角色标识, 返回 true 或 false
      *
      * @param loginId 账号id
-     * @param role 角色标识
+     * @param role    角色标识
      * @return 是否含有指定角色标识
      */
     public static boolean hasRole(Object loginId, String role) {
@@ -629,7 +630,7 @@ public class AdminStpUtil {
      * @param roleArray 角色标识数组
      * @return true或false
      */
-    public static boolean hasRoleAnd(String... roleArray){
+    public static boolean hasRoleAnd(String... roleArray) {
         return stpLogic.hasRoleAnd(roleArray);
     }
 
@@ -639,7 +640,7 @@ public class AdminStpUtil {
      * @param roleArray 角色标识数组
      * @return true或false
      */
-    public static boolean hasRoleOr(String... roleArray){
+    public static boolean hasRoleOr(String... roleArray) {
         return stpLogic.hasRoleOr(roleArray);
     }
 
@@ -657,7 +658,7 @@ public class AdminStpUtil {
      *
      * @param roleArray 角色标识数组
      */
-    public static void checkRoleAnd(String... roleArray){
+    public static void checkRoleAnd(String... roleArray) {
         stpLogic.checkRoleAnd(roleArray);
     }
 
@@ -666,7 +667,7 @@ public class AdminStpUtil {
      *
      * @param roleArray 角色标识数组
      */
-    public static void checkRoleOr(String... roleArray){
+    public static void checkRoleOr(String... roleArray) {
         stpLogic.checkRoleOr(roleArray);
     }
 
@@ -705,7 +706,7 @@ public class AdminStpUtil {
     /**
      * 判断:指定账号 id 是否含有指定权限, 返回 true 或 false
      *
-     * @param loginId 账号 id
+     * @param loginId    账号 id
      * @param permission 权限码
      * @return 是否含有指定权限
      */
@@ -719,7 +720,7 @@ public class AdminStpUtil {
      * @param permissionArray 权限码数组
      * @return true 或 false
      */
-    public static boolean hasPermissionAnd(String... permissionArray){
+    public static boolean hasPermissionAnd(String... permissionArray) {
         return stpLogic.hasPermissionAnd(permissionArray);
     }
 
@@ -729,7 +730,7 @@ public class AdminStpUtil {
      * @param permissionArray 权限码数组
      * @return true 或 false
      */
-    public static boolean hasPermissionOr(String... permissionArray){
+    public static boolean hasPermissionOr(String... permissionArray) {
         return stpLogic.hasPermissionOr(permissionArray);
     }
 
@@ -766,8 +767,8 @@ public class AdminStpUtil {
     /**
      * 获取指定账号 id 的 token
      * <p>
-     * 		在配置为允许并发登录时,此方法只会返回队列的最后一个 token,
-     * 		如果你需要返回此账号 id 的所有 token,请调用 getTokenValueListByLoginId
+     * 在配置为允许并发登录时,此方法只会返回队列的最后一个 token,
+     * 如果你需要返回此账号 id 的所有 token,请调用 getTokenValueListByLoginId
      * </p>
      *
      * @param loginId 账号id
@@ -780,12 +781,12 @@ public class AdminStpUtil {
     /**
      * 获取指定账号 id 指定设备类型端的 token
      * <p>
-     * 		在配置为允许并发登录时,此方法只会返回队列的最后一个 token,
-     * 		如果你需要返回此账号 id 的所有 token,请调用 getTokenValueListByLoginId
+     * 在配置为允许并发登录时,此方法只会返回队列的最后一个 token,
+     * 如果你需要返回此账号 id 的所有 token,请调用 getTokenValueListByLoginId
      * </p>
      *
      * @param loginId 账号id
-     * @param device 设备类型,填 null 代表不限设备类型
+     * @param device  设备类型,填 null 代表不限设备类型
      * @return token值
      */
     public static String getTokenValueByLoginId(Object loginId, String device) {
@@ -806,7 +807,7 @@ public class AdminStpUtil {
      * 获取指定账号 id 指定设备类型端的 token 集合
      *
      * @param loginId 账号id
-     * @param device 设备类型,填 null 代表不限设备类型
+     * @param device  设备类型,填 null 代表不限设备类型
      * @return 此 loginId 的所有登录 token
      */
     public static List<String> getTokenValueListByLoginId(Object loginId, String device) {
@@ -817,7 +818,7 @@ public class AdminStpUtil {
      * 获取指定账号 id 指定设备类型端的 tokenSign 集合
      *
      * @param loginId 账号id
-     * @param device 设备类型,填 null 代表不限设备类型
+     * @param device  设备类型,填 null 代表不限设备类型
      * @return 此 loginId 的所有登录 tokenSign
      */
     public static List<TokenSign> getTokenSignListByLoginId(Object loginId, String device) {
@@ -839,11 +840,10 @@ public class AdminStpUtil {
     /**
      * 根据条件查询缓存中所有的 token
      *
-     * @param keyword 关键字
-     * @param start 开始处索引
-     * @param size 获取数量 (-1代表一直获取到末尾)
+     * @param keyword  关键字
+     * @param start    开始处索引
+     * @param size     获取数量 (-1代表一直获取到末尾)
      * @param sortType 排序类型(true=正序,false=反序)
-     *
      * @return token集合
      */
     public static List<String> searchTokenValue(String keyword, int start, int size, boolean sortType) {
@@ -853,11 +853,10 @@ public class AdminStpUtil {
     /**
      * 根据条件查询缓存中所有的 SessionId
      *
-     * @param keyword 关键字
-     * @param start 开始处索引
-     * @param size 获取数量  (-1代表一直获取到末尾)
+     * @param keyword  关键字
+     * @param start    开始处索引
+     * @param size     获取数量  (-1代表一直获取到末尾)
      * @param sortType 排序类型(true=正序,false=反序)
-     *
      * @return sessionId集合
      */
     public static List<String> searchSessionId(String keyword, int start, int size, boolean sortType) {
@@ -867,11 +866,10 @@ public class AdminStpUtil {
     /**
      * 根据条件查询缓存中所有的 Token-Session-Id
      *
-     * @param keyword 关键字
-     * @param start 开始处索引
-     * @param size 获取数量 (-1代表一直获取到末尾)
+     * @param keyword  关键字
+     * @param start    开始处索引
+     * @param size     获取数量 (-1代表一直获取到末尾)
      * @param sortType 排序类型(true=正序,false=反序)
-     *
      * @return sessionId集合
      */
     public static List<String> searchTokenSessionId(String keyword, int start, int size, boolean sortType) {
@@ -886,7 +884,7 @@ public class AdminStpUtil {
      * <p> 此方法不会直接将此账号id踢下线,如需封禁后立即掉线,请追加调用 StpUtil.logout(id)
      *
      * @param loginId 指定账号id
-     * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+     * @param time    封禁时间, 单位: 秒 (-1=永久封禁)
      */
     public static void disable(Object loginId, long time) {
         stpLogic.disable(loginId, time);
@@ -939,7 +937,7 @@ public class AdminStpUtil {
      *
      * @param loginId 指定账号id
      * @param service 指定服务
-     * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+     * @param time    封禁时间, 单位: 秒 (-1=永久封禁)
      */
     public static void disable(Object loginId, String service, long time) {
         stpLogic.disable(loginId, service, time);
@@ -959,7 +957,7 @@ public class AdminStpUtil {
     /**
      * 校验:指定账号 指定服务 是否已被封禁,如果被封禁则抛出异常
      *
-     * @param loginId 账号id
+     * @param loginId  账号id
      * @param services 指定服务,可以指定多个
      */
     public static void checkDisable(Object loginId, String... services) {
@@ -980,7 +978,7 @@ public class AdminStpUtil {
     /**
      * 解封:指定账号、指定服务
      *
-     * @param loginId 账号id
+     * @param loginId  账号id
      * @param services 指定服务,可以指定多个
      */
     public static void untieDisable(Object loginId, String... services) {
@@ -994,8 +992,8 @@ public class AdminStpUtil {
      * 封禁:指定账号,并指定封禁等级
      *
      * @param loginId 指定账号id
-     * @param level 指定封禁等级
-     * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+     * @param level   指定封禁等级
+     * @param time    封禁时间, 单位: 秒 (-1=永久封禁)
      */
     public static void disableLevel(Object loginId, int level, long time) {
         stpLogic.disableLevel(loginId, level, time);
@@ -1006,8 +1004,8 @@ public class AdminStpUtil {
      *
      * @param loginId 指定账号id
      * @param service 指定封禁服务
-     * @param level 指定封禁等级
-     * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+     * @param level   指定封禁等级
+     * @param time    封禁时间, 单位: 秒 (-1=永久封禁)
      */
     public static void disableLevel(Object loginId, String service, int level, long time) {
         stpLogic.disableLevel(loginId, service, level, time);
@@ -1017,7 +1015,7 @@ public class AdminStpUtil {
      * 判断:指定账号是否已被封禁到指定等级
      *
      * @param loginId 指定账号id
-     * @param level 指定封禁等级
+     * @param level   指定封禁等级
      * @return /
      */
     public static boolean isDisableLevel(Object loginId, int level) {
@@ -1029,7 +1027,7 @@ public class AdminStpUtil {
      *
      * @param loginId 指定账号id
      * @param service 指定封禁服务
-     * @param level 指定封禁等级
+     * @param level   指定封禁等级
      * @return /
      */
     public static boolean isDisableLevel(Object loginId, String service, int level) {
@@ -1040,7 +1038,7 @@ public class AdminStpUtil {
      * 校验:指定账号是否已被封禁到指定等级(如果已经达到,则抛出异常)
      *
      * @param loginId 指定账号id
-     * @param level 封禁等级 (只有 封禁等级 ≥ 此值 才会抛出异常)
+     * @param level   封禁等级 (只有 封禁等级 ≥ 此值 才会抛出异常)
      */
     public static void checkDisableLevel(Object loginId, int level) {
         stpLogic.checkDisableLevel(loginId, level);
@@ -1051,7 +1049,7 @@ public class AdminStpUtil {
      *
      * @param loginId 指定账号id
      * @param service 指定封禁服务
-     * @param level 封禁等级 (只有 封禁等级 ≥ 此值 才会抛出异常)
+     * @param level   封禁等级 (只有 封禁等级 ≥ 此值 才会抛出异常)
      */
     public static void checkDisableLevel(Object loginId, String service, int level) {
         stpLogic.checkDisableLevel(loginId, service, level);
@@ -1109,7 +1107,7 @@ public class AdminStpUtil {
     /**
      * 在一个 lambda 代码段里,临时切换身份为指定账号id,lambda 结束后自动恢复
      *
-     * @param loginId 指定账号id
+     * @param loginId  指定账号id
      * @param function 要执行的方法
      */
     public static void switchTo(Object loginId, SaFunction function) {
@@ -1131,7 +1129,7 @@ public class AdminStpUtil {
     /**
      * 在当前会话 开启二级认证
      *
-     * @param service 业务标识
+     * @param service  业务标识
      * @param safeTime 维持时间 (单位: 秒)
      */
     public static void openSafe(String service, long safeTime) {
@@ -1161,7 +1159,7 @@ public class AdminStpUtil {
      * 判断:指定 token 是否处于二级认证时间内
      *
      * @param tokenValue Token 值
-     * @param service 业务标识
+     * @param service    业务标识
      * @return true=二级认证已通过, false=尚未进行二级认证或认证已超时
      */
     public static boolean isSafe(String tokenValue, String service) {

+ 10 - 22
src/main/java/cc/uncarbon/module/adminapi/web/auth/AdminAuthController.java

@@ -11,26 +11,23 @@ import cc.uncarbon.framework.web.model.response.ApiResult;
 import cc.uncarbon.helper.CaptchaHelper;
 import cc.uncarbon.helper.RolePermissionCacheHelper;
 import cc.uncarbon.module.adminapi.constant.AdminApiConstant;
+import cc.uncarbon.module.adminapi.model.interior.AdminCaptchaContainer;
+import cc.uncarbon.module.adminapi.model.response.AdminCaptchaVO;
+import cc.uncarbon.module.adminapi.util.AdminStpUtil;
 import cc.uncarbon.module.sys.annotation.SysLog;
 import cc.uncarbon.module.sys.constant.SysConstant;
-import cc.uncarbon.module.sys.enums.SysErrorEnum;
 import cc.uncarbon.module.sys.model.request.SysUserLoginDTO;
 import cc.uncarbon.module.sys.model.response.SysUserLoginBO;
 import cc.uncarbon.module.sys.model.response.SysUserLoginVO;
 import cc.uncarbon.module.sys.service.SysUserService;
-import cc.uncarbon.module.adminapi.util.AdminStpUtil;
 import cn.dev33.satoken.annotation.SaCheckLogin;
-import cn.hutool.captcha.AbstractCaptcha;
 import io.swagger.annotations.Api;
-import io.swagger.annotations.ApiImplicitParam;
 import io.swagger.annotations.ApiOperation;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 
-import javax.servlet.http.HttpServletResponse;
 import javax.validation.Valid;
-import java.io.IOException;
 
 
 @Api(value = "后台管理-鉴权接口", tags = {"后台管理-鉴权接口"})
@@ -55,6 +52,9 @@ public class AdminAuthController {
     @ApiOperation(value = "登录")
     @PostMapping(value = "/auth/login")
     public ApiResult<SysUserLoginVO> login(@RequestBody @Valid SysUserLoginDTO dto) {
+        // 登录验证码核验;前端项目搜索关键词「 Helio: 登录验证码」
+        // AdminApiErrorEnum.CAPTCHA_VALIDATE_FAILED.assertTrue(captchaHelper.validate(dto.getCaptchaId(), dto.getCaptchaAnswer()))
+
         // RPC调用, 失败抛异常, 成功返回用户信息
         SysUserLoginBO userInfo = sysUserService.adminLogin(dto);
 
@@ -99,24 +99,12 @@ public class AdminAuthController {
         return ApiResult.success();
     }
 
-    @ApiOperation(value = "验证码图片")
-    @ApiImplicitParam(name = "uuid", value = "验证码图片UUID", required = true)
+    @ApiOperation(value = "获取验证码")
     @GetMapping(value = "/auth/captcha")
-    public void captcha(HttpServletResponse response, String uuid) throws IOException {
-        /*
-        由前端定义 UUID 其实并不算太好的办法,但是够简单
-        更复杂而安全的做法是:由后端生成一个 UUID,通过响应头返回给前端(这对前端有一定的技能技术要求)
-         */
-        // uuid 为空则抛出异常
-        SysErrorEnum.UUID_CANNOT_BE_BLANK.assertNotBlank(uuid);
-
+    public ApiResult<AdminCaptchaVO> captcha() {
         // 核验方法:captchaHelper.validate
-        AbstractCaptcha captcha = captchaHelper.generate(uuid);
-
-        // 写入响应流
-        response.setHeader("Cache-Control", "no-store, no-cache");
-        response.setContentType("image/png");
-        captcha.write(response.getOutputStream());
+        AdminCaptchaContainer captchaContainer = captchaHelper.generate();
+        return ApiResult.data(new AdminCaptchaVO(captchaContainer));
     }
 
 }

+ 18 - 37
src/main/java/cc/uncarbon/module/adminapi/web/common/AdminSelectOptionsController.java

@@ -1,16 +1,15 @@
 package cc.uncarbon.module.adminapi.web.common;
 
 
-import cc.uncarbon.framework.core.enums.GenderEnum;
-import cc.uncarbon.framework.core.enums.YesOrNoEnum;
 import cc.uncarbon.framework.web.model.response.ApiResult;
 import cc.uncarbon.module.adminapi.constant.AdminApiConstant;
 import cc.uncarbon.module.adminapi.model.response.SelectOptionItemVO;
+import cc.uncarbon.module.adminapi.util.AdminStpUtil;
 import cc.uncarbon.module.sys.model.response.SysDeptBO;
+import cc.uncarbon.module.sys.model.response.SysRoleBO;
 import cc.uncarbon.module.sys.service.SysDeptService;
-import cc.uncarbon.module.adminapi.util.AdminStpUtil;
+import cc.uncarbon.module.sys.service.SysRoleService;
 import cn.dev33.satoken.annotation.SaCheckLogin;
-import cn.dev33.satoken.annotation.SaIgnore;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import lombok.RequiredArgsConstructor;
@@ -30,47 +29,29 @@ import java.util.List;
 @Slf4j
 public class AdminSelectOptionsController {
 
+    private final SysRoleService sysRoleService;
     private final SysDeptService sysDeptService;
 
 
     /*
     这里统一存放所有用于后台管理的下拉框数据源接口
-    避免多人协作时,不知道原来是否已经有了,或者写在某个边边角角里
-    造成重复开发
-     */
-
-    // 👇该注解标记的接口不需要登录
-    @SaIgnore
-    @ApiOperation(value = "(演示接口;可删除)性别下拉框")
-    @GetMapping(value = "/select-options/genders")
-    public ApiResult<List<SelectOptionItemVO>> genders(YesOrNoEnum demo) {
-        if (demo == YesOrNoEnum.YES) {
-            // demo=YES时,不输出「未知」、多输出demoString1
-            List<SelectOptionItemVO> ret = SelectOptionItemVO.listOf(GenderEnum.class, item -> item != GenderEnum.UNKNOWN);
-            for (SelectOptionItemVO item : ret) {
-                item.setDemoString1("文本字段1-" + item.getValue());
-            }
-            return ApiResult.data(ret);
-        }
-        // 默认返回
-        return ApiResult.data(SelectOptionItemVO.listOf(GenderEnum.class));
+    避免多人协作时,不知道原来是否已经有了,或者写在某个边边角角里,造成重复开发
+    */
+
+    @ApiOperation(value = "后台角色下拉框")
+    @GetMapping(value = "/select-options/roles")
+    public ApiResult<List<SelectOptionItemVO>> roles() {
+        return ApiResult.data(
+                SelectOptionItemVO.listOf(sysRoleService.adminSelectOptions(), SysRoleBO::getId, SysRoleBO::getTitle)
+        );
     }
 
-    // 👇该注解标记的接口不需要登录
-    @SaIgnore
-    @ApiOperation(value = "(演示接口;可删除)部门下拉框")
+    @ApiOperation(value = "部门下拉框(前端负责转为树状数据)")
     @GetMapping(value = "/select-options/depts")
-    public ApiResult<List<SelectOptionItemVO>> depts(YesOrNoEnum demo) {
-        if (demo == YesOrNoEnum.YES) {
-            // demo=YES时,多输出上级ID、多输出demoString1
-            List<SelectOptionItemVO> ret = SelectOptionItemVO.listOf(sysDeptService.adminList(), SysDeptBO::getId, SysDeptBO::getTitle, SysDeptBO::getParentId);
-            for (SelectOptionItemVO item : ret) {
-                item.setDemoString1("文本字段1-" + item.getId());
-            }
-            return ApiResult.data(ret);
-        }
-        // 默认返回
-        return ApiResult.data(SelectOptionItemVO.listOf(sysDeptService.adminList(), SysDeptBO::getId, SysDeptBO::getTitle));
+    public ApiResult<List<SelectOptionItemVO>> depts() {
+        return ApiResult.data(
+                SelectOptionItemVO.listOf(sysDeptService.adminSelectOptions(true), SysDeptBO::getId, SysDeptBO::getTitle, SysDeptBO::getParentId)
+        );
     }
 
 }

+ 1 - 2
src/main/java/cc/uncarbon/module/adminapi/web/oss/AdminOssUploadDownloadController.java

@@ -68,8 +68,7 @@ public class AdminOssUploadDownloadController {
             attr
                     .setOriginalFilename(file.getOriginalFilename())
                     .setContentType(file.getContentType())
-                    .setMd5(md5)
-            ;
+                    .setMd5(md5);
             bo = ossUploadDownloadFacade.upload(file.getBytes(), attr);
         }
 

+ 1 - 1
src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysDataDictController.java

@@ -36,7 +36,7 @@ import javax.validation.Valid;
 @Slf4j
 public class AdminSysDataDictController {
 
-    private static final String PERMISSION_PREFIX = "SysDataDict:" ;
+    private static final String PERMISSION_PREFIX = "SysDataDict:";
 
     private final SysDataDictService sysDataDictService;
 

+ 4 - 1
src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysRoleController.java

@@ -65,6 +65,7 @@ public class AdminSysRoleController {
     @ApiOperation(value = "新增")
     @PostMapping(value = "/sys/roles")
     public ApiResult<Void> insert(@RequestBody @Valid AdminInsertOrUpdateSysRoleDTO dto) {
+        dto.setTenantId(null);
         sysRoleService.adminInsert(dto);
 
         return ApiResult.success();
@@ -75,7 +76,9 @@ public class AdminSysRoleController {
     @ApiOperation(value = "编辑")
     @PutMapping(value = "/sys/roles/{id}")
     public ApiResult<Void> update(@PathVariable Long id, @RequestBody @Valid AdminInsertOrUpdateSysRoleDTO dto) {
-        dto.setId(id);
+        dto
+                .setTenantId(null)
+                .setId(id);
         sysRoleService.adminUpdate(dto);
 
         return ApiResult.success();

+ 16 - 3
src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysTenantController.java

@@ -6,6 +6,8 @@ import cc.uncarbon.framework.core.page.PageResult;
 import cc.uncarbon.framework.web.model.request.IdsDTO;
 import cc.uncarbon.framework.web.model.response.ApiResult;
 import cc.uncarbon.module.adminapi.constant.AdminApiConstant;
+import cc.uncarbon.module.adminapi.event.KickOutSysUsersEvent;
+import cc.uncarbon.module.adminapi.util.AdminStpUtil;
 import cc.uncarbon.module.sys.annotation.SysLog;
 import cc.uncarbon.module.sys.constant.SysConstant;
 import cc.uncarbon.module.sys.facade.SysTenantFacade;
@@ -13,10 +15,11 @@ import cc.uncarbon.module.sys.model.request.AdminInsertSysTenantDTO;
 import cc.uncarbon.module.sys.model.request.AdminListSysTenantDTO;
 import cc.uncarbon.module.sys.model.request.AdminUpdateSysTenantDTO;
 import cc.uncarbon.module.sys.model.response.SysTenantBO;
+import cc.uncarbon.module.sys.model.response.SysTenantKickOutUsersBO;
 import cc.uncarbon.module.sys.service.SysTenantService;
-import cc.uncarbon.module.adminapi.util.AdminStpUtil;
 import cn.dev33.satoken.annotation.SaCheckLogin;
 import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.hutool.extra.spring.SpringUtil;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import lombok.RequiredArgsConstructor;
@@ -75,7 +78,12 @@ public class AdminSysTenantController {
     @PutMapping(value = "/sys/tenants/{id}")
     public ApiResult<Void> update(@PathVariable Long id, @RequestBody @Valid AdminUpdateSysTenantDTO dto) {
         dto.setId(id);
-        sysTenantService.adminUpdate(dto);
+        SysTenantKickOutUsersBO needKickOutUsers = sysTenantFacade.adminUpdate(dto);
+
+        // 强制登出所有租户用户
+        SpringUtil.publishEvent(new KickOutSysUsersEvent(
+                new KickOutSysUsersEvent.EventData(needKickOutUsers.getSysUserIds())
+        ));
 
         return ApiResult.success();
     }
@@ -85,7 +93,12 @@ public class AdminSysTenantController {
     @ApiOperation(value = "删除")
     @DeleteMapping(value = "/sys/tenants")
     public ApiResult<Void> delete(@RequestBody @Valid IdsDTO<Long> dto) {
-        sysTenantService.adminDelete(dto.getIds());
+        SysTenantKickOutUsersBO needKickOutUsers = sysTenantFacade.adminDelete(dto.getIds());
+
+        // 强制登出所有租户用户
+        SpringUtil.publishEvent(new KickOutSysUsersEvent(
+                new KickOutSysUsersEvent.EventData(needKickOutUsers.getSysUserIds())
+        ));
 
         return ApiResult.success();
     }

+ 9 - 0
src/main/java/cc/uncarbon/module/adminapi/web/sys/AdminSysUserController.java

@@ -9,6 +9,7 @@ import cc.uncarbon.framework.web.model.response.ApiResult;
 import cc.uncarbon.module.adminapi.constant.AdminApiConstant;
 import cc.uncarbon.module.sys.annotation.SysLog;
 import cc.uncarbon.module.sys.constant.SysConstant;
+import cc.uncarbon.module.sys.enums.SysUserStatusEnum;
 import cc.uncarbon.module.sys.model.request.*;
 import cc.uncarbon.module.sys.model.response.SysUserBO;
 import cc.uncarbon.module.sys.model.response.VbenAdminUserInfoVO;
@@ -75,6 +76,11 @@ public class AdminSysUserController {
         dto.setId(id);
         sysUserService.adminUpdate(dto);
 
+        // 新状态是禁用,连带踢出登录
+        if (dto.getStatus() == SysUserStatusEnum.BANNED) {
+            kickOut(dto.getId());
+        }
+
         return ApiResult.success();
     }
 
@@ -85,6 +91,9 @@ public class AdminSysUserController {
     public ApiResult<Void> delete(@RequestBody @Valid IdsDTO<Long> dto) {
         sysUserService.adminDelete(dto.getIds());
 
+        // 连带踢出登录
+        dto.getIds().forEach(this::kickOut);
+
         return ApiResult.success();
     }
 

+ 1 - 1
src/main/java/cc/uncarbon/module/appapi/constant/AppApiConstant.java

@@ -4,7 +4,7 @@ package cc.uncarbon.module.appapi.constant;
 /**
  * C端接口常量
  */
-public class AppApiConstant {
+public final class AppApiConstant {
     private AppApiConstant() {
     }
 

+ 1 - 1
src/main/java/cc/uncarbon/module/oss/constant/OssConstant.java

@@ -1,6 +1,6 @@
 package cc.uncarbon.module.oss.constant;
 
-public class OssConstant {
+public final class OssConstant {
     private OssConstant() {
     }
 

+ 1 - 1
src/main/java/cc/uncarbon/module/oss/enums/OssErrorEnum.java

@@ -7,7 +7,7 @@ import lombok.Getter;
 
 
 /**
- * OSS异常枚举类
+ * oss模块错误枚举类
  */
 @AllArgsConstructor
 @Getter

+ 2 - 2
src/main/java/cc/uncarbon/module/oss/mapper/OssFileInfoMapper.java

@@ -1,7 +1,7 @@
 package cc.uncarbon.module.oss.mapper;
 
-import cc.uncarbon.framework.crud.mapper.HelioBaseMapper;
 import cc.uncarbon.module.oss.entity.OssFileInfoEntity;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import org.apache.ibatis.annotations.Mapper;
 
 
@@ -9,6 +9,6 @@ import org.apache.ibatis.annotations.Mapper;
  * 上传文件信息
  */
 @Mapper
-public interface OssFileInfoMapper extends HelioBaseMapper<OssFileInfoEntity, Long> {
+public interface OssFileInfoMapper extends BaseMapper<OssFileInfoEntity> {
 
 }

+ 0 - 58
src/main/java/cc/uncarbon/module/oss/model/request/AdminInsertOrUpdateOssFileInfoDTO.java

@@ -1,58 +0,0 @@
-package cc.uncarbon.module.oss.model.request;
-
-import cc.uncarbon.framework.core.enums.EnabledStatusEnum;
-import io.swagger.annotations.ApiModelProperty;
-import lombok.AllArgsConstructor;
-import lombok.Data;
-import lombok.NoArgsConstructor;
-import lombok.experimental.Accessors;
-import lombok.experimental.SuperBuilder;
-
-import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.NotNull;
-import java.io.Serializable;
-
-
-/**
- * 后台管理-新增/编辑上传文件信息 DTO
- */
-@Accessors(chain = true)
-@SuperBuilder
-@AllArgsConstructor
-@NoArgsConstructor
-@Data
-public class AdminInsertOrUpdateOssFileInfoDTO implements Serializable {
-
-    private static final long serialVersionUID = 1L;
-
-    @ApiModelProperty(value = "主键ID", hidden = true, notes = "仅更新时使用")
-    private Long id;
-
-    @ApiModelProperty(value = "前置路径", required = true)
-    @NotBlank(message = "前置路径不能为空")
-    private String path;
-
-    @ApiModelProperty(value = "文件名", required = true)
-    @NotBlank(message = "文件名不能为空")
-    private String filename;
-
-    @ApiModelProperty(value = "扩展名", required = true)
-    @NotBlank(message = "扩展名不能为空")
-    private String extendName;
-
-    @ApiModelProperty(value = "文件大小", required = true)
-    @NotNull(message = "文件大小不能为空")
-    private Long filesize;
-
-    @ApiModelProperty(value = "MD5", required = true)
-    @NotBlank(message = "MD5不能为空")
-    private String md5;
-
-    @ApiModelProperty(value = "状态", required = true)
-    @NotNull(message = "状态不能为空")
-    private EnabledStatusEnum status;
-
-    @ApiModelProperty(value = "类别编号")
-    private Integer classified;
-
-}

+ 2 - 3
src/main/java/cc/uncarbon/module/oss/service/OssFileInfoService.java

@@ -101,7 +101,7 @@ public class OssFileInfoService {
         log.info("[后台管理-删除上传文件信息] >> ids={}", ids);
 
         // 1. 删除原始文件
-        List<OssFileInfoEntity> entityList = ossFileInfoMapper.selectByIds(ids);
+        List<OssFileInfoEntity> entityList = ossFileInfoMapper.selectBatchIds(ids);
         for (OssFileInfoEntity entity : entityList) {
             fileStorageService.delete(toFileInfo(entity));
         }
@@ -244,8 +244,7 @@ public class OssFileInfoService {
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                .setRecords(this.entityList2BOs(entityPage.getRecords()));
     }
 
 }

+ 12 - 1
src/main/java/cc/uncarbon/module/sys/constant/SysConstant.java

@@ -4,7 +4,7 @@ package cc.uncarbon.module.sys.constant;
 /**
  * 系统管理常量
  */
-public class SysConstant {
+public final class SysConstant {
     private SysConstant() {
     }
 
@@ -30,4 +30,15 @@ public class SysConstant {
      */
     public static final Long SUPER_ADMIN_ROLE_ID = 1L;
 
+    /**
+     * 超级管理员角色值(固定)
+     */
+    public static final String SUPER_ADMIN_ROLE_VALUE = "SuperAdmin";
+
+    /**
+     * 租户管理员角色值
+     * 为了外显美观没有在前面增加Tenant字样
+     */
+    public static final String TENANT_ADMIN_ROLE_VALUE = "Admin";
+
 }

+ 2 - 2
src/main/java/cc/uncarbon/module/sys/entity/SysDataDictEntity.java

@@ -36,11 +36,11 @@ public class SysDataDictEntity extends HelioBaseEntity<Long> {
 	@TableField(value = "pascal_case_key")
 	private String pascalCaseKey;
 
-	@ApiModelProperty(value = "值")
+	@ApiModelProperty(value = "数据值")
 	@TableField(value = "value")
 	private String value;
 
-	@ApiModelProperty(value = "参数描述")
+	@ApiModelProperty(value = "描述")
 	@TableField(value = "description")
 	private String description;
 

+ 17 - 2
src/main/java/cc/uncarbon/module/sys/entity/SysRoleEntity.java

@@ -1,6 +1,7 @@
 package cc.uncarbon.module.sys.entity;
 
 import cc.uncarbon.framework.crud.entity.HelioBaseEntity;
+import cc.uncarbon.module.sys.constant.SysConstant;
 import com.baomidou.mybatisplus.annotation.TableField;
 import com.baomidou.mybatisplus.annotation.TableName;
 import io.swagger.annotations.ApiModelProperty;
@@ -24,12 +25,26 @@ import lombok.experimental.SuperBuilder;
 @TableName(value = "sys_role")
 public class SysRoleEntity extends HelioBaseEntity<Long> {
 
-	@ApiModelProperty(value = "名")
+	@ApiModelProperty(value = "角色名")
 	@TableField(value = "title")
 	private String title;
 
-	@ApiModelProperty(value = "")
+	@ApiModelProperty(value = "角色编码")
 	@TableField(value = "value")
 	private String value;
 
+	/**
+	 * 角色实例可被视为超级管理员
+	 */
+	public boolean isSuperAdmin() {
+		return SysConstant.SUPER_ADMIN_ROLE_ID.equals(getId()) || SysConstant.SUPER_ADMIN_ROLE_VALUE.equalsIgnoreCase(getValue());
+	}
+
+	/**
+	 * 角色实例可被视为租户管理员
+	 */
+	public boolean isTenantAdmin() {
+		return SysConstant.TENANT_ADMIN_ROLE_VALUE.equalsIgnoreCase(getValue());
+	}
+
 }

+ 35 - 2
src/main/java/cc/uncarbon/module/sys/enums/SysErrorEnum.java

@@ -6,7 +6,7 @@ import lombok.Getter;
 
 
 /**
- * SYS异常枚举类
+ * sys模块错误枚举类
  */
 @AllArgsConstructor
 @Getter
@@ -28,7 +28,40 @@ public enum SysErrorEnum implements HelioBaseEnum<Integer> {
 
     NO_MENU_AVAILABLE_FOR_CURRENT_ROLE(400, "当前角色没有可用菜单"),
 
-    UUID_CANNOT_BE_BLANK(400, "UUID不能为空"),
+    /*
+    以下8个枚举用于后台角色管理的越权检查
+    */
+
+    ROLE_VALUE_CANNOT_BE(403, "角色值 {} 不能用于新增或编辑,请选用其他值"),
+
+    CANNOT_DELETE_SUPER_ADMIN_ROLE(403, "不能删除超级管理员角色"),
+
+    CANNOT_DELETE_TENANT_ADMIN_ROLE(403, "为减少脏数据,不建议直接删除租户管理员角色,需通过【删除租户】关联删除"),
+
+    CANNOT_DELETE_SELF_ROLE(403, "不能删除自身角色"),
+
+    CANNOT_BIND_MENUS_FOR_SUPER_ADMIN_ROLE(403, "不能为超级管理员角色绑定菜单"),
+
+    CANNOT_BIND_MENUS_FOR_SELF(403, "不能为自身角色绑定菜单"),
+
+    BEYOND_AUTHORITY_BIND_MENUS(401, "不得超越自身菜单权限"),
+
+    CANNOT_BIND_MENUS_FOR_TENANT_ADMIN_ROLE(403, "无权为租户管理员绑定菜单"),
+
+    /*
+    以下4个枚举用于后台用户管理的越权检查
+    */
+    CANNOT_OPERATE_SELF_USER(403, "不能对自身进行此操作"),
+
+    CANNOT_OPERATE_THIS_USER(403, "不能该用户进行此操作"),
+
+    CANNOT_UNBIND_SELF_TENANT_ADMIN_ROLE(403, "自身的管理员角色不能被取消"),
+
+    BEYOND_AUTHORITY_BIND_ROLES(401, "不得超越自身角色权限"),
+
+    CANNOT_DELETE_PRIVILEGED_TENANT(403, "不能删除超级租户"),
+
+    NEED_DELETE_EXISTING_TENANT_ADMIN_ROLE(500, "租户ID {} 对应的租户管理员角色已存在,请使用超级管理员账号删除"),
 
     ;
     private final Integer value;

+ 3 - 0
src/main/java/cc/uncarbon/module/sys/extension/impl/DefaultSysLogAspectExtension.java

@@ -6,6 +6,7 @@ import cn.hutool.core.net.NetUtil;
 import cn.hutool.core.text.CharSequenceUtil;
 import cn.hutool.core.text.StrPool;
 import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.http.Header;
 import cn.hutool.http.HttpRequest;
 import cn.hutool.http.HttpResponse;
 import cn.hutool.json.JSONObject;
@@ -31,6 +32,8 @@ public class DefaultSysLogAspectExtension implements SysLogAspectExtension {
                 .form("ip", ip)
                 .form("json", Boolean.TRUE.toString())
                 .charset(CharsetUtil.CHARSET_GBK)
+                // since 1.11.0,加个UA避免被当成恶意请求,造成查IP失败
+                .header(Header.USER_AGENT, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.0.0 Safari/537.36")
                 .timeout(5000);
         try (HttpResponse httpResponse = httpRequest.execute()) {
             String repStr = httpResponse.body();

+ 15 - 1
src/main/java/cc/uncarbon/module/sys/facade/SysTenantFacade.java

@@ -1,9 +1,13 @@
 package cc.uncarbon.module.sys.facade;
 
 import cc.uncarbon.module.sys.model.request.AdminInsertSysTenantDTO;
+import cc.uncarbon.module.sys.model.request.AdminUpdateSysTenantDTO;
+import cc.uncarbon.module.sys.model.response.SysTenantKickOutUsersBO;
+
+import java.util.Collection;
 
 /**
- * 系统租户防腐层,用于解决循环依赖
+ * 系统租户解耦层,用于解决循环依赖
  */
 public interface SysTenantFacade {
 
@@ -12,4 +16,14 @@ public interface SysTenantFacade {
      */
     Long adminInsert(AdminInsertSysTenantDTO dto);
 
+    /**
+     * 后台管理-编辑
+     */
+    SysTenantKickOutUsersBO adminUpdate(AdminUpdateSysTenantDTO dto);
+
+    /**
+     * 后台管理-删除
+     */
+    SysTenantKickOutUsersBO adminDelete(Collection<Long> ids);
+
 }

+ 81 - 14
src/main/java/cc/uncarbon/module/sys/facade/impl/SysTenantFacadeImpl.java

@@ -1,21 +1,34 @@
 package cc.uncarbon.module.sys.facade.impl;
 
+import cc.uncarbon.framework.core.constant.HelioConstant;
+import cc.uncarbon.framework.core.enums.EnabledStatusEnum;
+import cc.uncarbon.framework.core.function.StreamFunction;
+import cc.uncarbon.module.sys.constant.SysConstant;
 import cc.uncarbon.module.sys.entity.SysTenantEntity;
+import cc.uncarbon.module.sys.enums.SysErrorEnum;
+import cc.uncarbon.module.sys.enums.SysUserStatusEnum;
 import cc.uncarbon.module.sys.facade.SysTenantFacade;
 import cc.uncarbon.module.sys.model.request.AdminInsertOrUpdateSysRoleDTO;
 import cc.uncarbon.module.sys.model.request.AdminInsertOrUpdateSysUserDTO;
 import cc.uncarbon.module.sys.model.request.AdminInsertSysTenantDTO;
+import cc.uncarbon.module.sys.model.request.AdminUpdateSysTenantDTO;
+import cc.uncarbon.module.sys.model.response.SysTenantBO;
+import cc.uncarbon.module.sys.model.response.SysTenantKickOutUsersBO;
 import cc.uncarbon.module.sys.service.SysRoleService;
 import cc.uncarbon.module.sys.service.SysTenantService;
 import cc.uncarbon.module.sys.service.SysUserRoleRelationService;
 import cc.uncarbon.module.sys.service.SysUserService;
+import cn.hutool.core.collection.CollUtil;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.*;
+import java.util.stream.Collectors;
+
 /**
- * 系统租户防腐层,用于解决循环依赖
+ * 系统租户Facade接口实现类
  */
 @RequiredArgsConstructor
 @Service
@@ -50,38 +63,92 @@ public class SysTenantFacadeImpl implements SysTenantFacade {
         Long newRoleId = sysRoleService.adminInsert(
                 AdminInsertOrUpdateSysRoleDTO.builder()
                         .tenantId(newTenantId)
-                        .title(dto.getTenantName() + "管理员")
-                        .value("Admin")
+                        .title(dto.getTenantName() + "管理员")
+                        .value(SysConstant.TENANT_ADMIN_ROLE_VALUE)
                         .build()
         );
 
-        /*
-        3. 创建一个新用户
-         */
+        // 3. 创建一个新用户
         Long newUserId = sysUserService.adminInsert(
                 AdminInsertOrUpdateSysUserDTO.builder()
                         .tenantId(newTenantId)
                         .username(dto.getTenantAdminUsername())
                         .passwordOfNewUser(dto.getTenantAdminPassword())
-                        .nickname(dto.getTenantName() + "管理员")
+                        .nickname(dto.getTenantName() + "管理员")
                         .email(dto.getTenantAdminEmail())
                         .phoneNo(dto.getTenantAdminPhoneNo())
+                        // 默认为正常状态
+                        .status(SysUserStatusEnum.ENABLED)
                         .build()
         );
 
-        /*
-        4. 将新用户绑定至新角色上
-         */
+        // 4. 将新用户绑定至新角色上
         sysUserRoleRelationService.adminInsert(newTenantId, newUserId, newRoleId);
 
-        /*
-        5. 把管理员账号更新进库
-         */
+        // 5. 把管理员账号更新进库
         SysTenantEntity update = new SysTenantEntity().setTenantAdminUserId(newUserId);
         update.setId(newTenantEntityId);
         sysTenantService.adminUpdate(update);
-
         return newTenantId;
     }
 
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public SysTenantKickOutUsersBO adminUpdate(AdminUpdateSysTenantDTO dto) {
+        sysTenantService.adminUpdate(dto);
+
+        if (dto.getStatus() == EnabledStatusEnum.DISABLED) {
+            // 新状态是禁用,查出需要强制登出的用户
+            Long tenantId = CollUtil.getFirst(determineTenantIdsByPrimaryKeys(Collections.singleton(dto.getId())).values());
+            if (Objects.nonNull(tenantId)) {
+                List<Long> tenantSysUserIds = sysUserService.listUserIdsByTenantId(tenantId, Collections.singleton(EnabledStatusEnum.ENABLED));
+                return new SysTenantKickOutUsersBO(tenantSysUserIds);
+            }
+        }
+        return new SysTenantKickOutUsersBO();
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public SysTenantKickOutUsersBO adminDelete(Collection<Long> ids) {
+        Collection<Long> tenantIds = determineTenantIdsByPrimaryKeys(ids).values();
+        if (CollUtil.isNotEmpty(tenantIds)) {
+            // 不能删除「超级租户」(租户ID=0)
+            SysErrorEnum.CANNOT_DELETE_PRIVILEGED_TENANT.assertNotContains(tenantIds, HelioConstant.Tenant.DEFAULT_PRIVILEGED_TENANT_ID);
+
+            // 删除租户管理员角色、租户
+            sysRoleService.adminDeleteTenantRoles(tenantIds, Collections.singleton(SysConstant.TENANT_ADMIN_ROLE_VALUE));
+            sysTenantService.adminDelete(ids);
+
+            // 查出需要强制登出的用户
+            List<Long> tenantSysUserIds = tenantIds.stream()
+                    .map(tenantId -> sysUserService.listUserIdsByTenantId(tenantId, Collections.singleton(EnabledStatusEnum.ENABLED)))
+                    .flatMap(Collection::stream)
+                    .collect(Collectors.toList());
+            return new SysTenantKickOutUsersBO(tenantSysUserIds);
+        }
+        return new SysTenantKickOutUsersBO();
+    }
+
+    /*
+    ----------------------------------------------------------------
+                        私有方法 private methods
+    ----------------------------------------------------------------
+     */
+
+    /**
+     * 根据租户主键ID,确定租户IDs
+     * @return map[主键ID, 租户ID]
+     */
+    private Map<Long, Long> determineTenantIdsByPrimaryKeys(Collection<Long> ids) {
+        if (CollUtil.isEmpty(ids)) {
+            return Collections.emptyMap();
+        }
+        List<SysTenantBO> sysTenantInfos = sysTenantService.listByIds(ids, false);
+        if (CollUtil.isEmpty(sysTenantInfos)) {
+            return Collections.emptyMap();
+        }
+        return sysTenantInfos.stream().collect(Collectors.toMap(SysTenantBO::getId, SysTenantBO::getTenantId, StreamFunction.ignoredThrowingMerger()));
+    }
+
 }

+ 11 - 1
src/main/java/cc/uncarbon/module/sys/mapper/SysDeptMapper.java

@@ -1,13 +1,23 @@
 package cc.uncarbon.module.sys.mapper;
 
 import cc.uncarbon.module.sys.entity.SysDeptEntity;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import org.apache.ibatis.annotations.Mapper;
 
+import java.util.List;
+
 /**
  * 部门
  */
 @Mapper
 public interface SysDeptMapper extends BaseMapper<SysDeptEntity> {
-	
+
+    /**
+     * 列举已排序好的所有部门列表
+     */
+    default List<SysDeptEntity> sortedList() {
+        return selectList(new LambdaQueryWrapper<SysDeptEntity>().orderByAsc(SysDeptEntity::getSort));
+    }
+
 }

+ 1 - 1
src/main/java/cc/uncarbon/module/sys/mapper/SysRoleMapper.java

@@ -9,5 +9,5 @@ import org.apache.ibatis.annotations.Mapper;
  */
 @Mapper
 public interface SysRoleMapper extends BaseMapper<SysRoleEntity> {
-	
+
 }

+ 21 - 0
src/main/java/cc/uncarbon/module/sys/mapper/SysUserMapper.java

@@ -1,12 +1,19 @@
 package cc.uncarbon.module.sys.mapper;
 
+import cc.uncarbon.framework.core.enums.EnabledStatusEnum;
 import cc.uncarbon.module.sys.entity.SysUserEntity;
 import cc.uncarbon.module.sys.model.response.SysUserBaseInfoBO;
+import cn.hutool.core.collection.CollUtil;
 import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.mapper.BaseMapper;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 
+import java.util.Collection;
+import java.util.List;
+import java.util.stream.Collectors;
+
 /**
  * 后台用户
  */
@@ -29,4 +36,18 @@ public interface SysUserMapper extends BaseMapper<SysUserEntity> {
     @InterceptorIgnore(tenantLine = "true")
     SysUserBaseInfoBO getBaseInfoByUserId(@Param(value = "userId") Long userId);
 
+    /**
+     * 查询所有用户IDs
+     * @param statusEnums 仅保留符合指定状态的,可以为null
+     */
+    default List<Long> selectIds(Collection<EnabledStatusEnum> statusEnums) {
+        return selectList(
+                new LambdaQueryWrapper<SysUserEntity>()
+                        // 只取主键ID
+                        .select(SysUserEntity::getId)
+                        // 状态
+                        .in(CollUtil.isNotEmpty(statusEnums), SysUserEntity::getStatus, statusEnums)
+        ).stream().map(SysUserEntity::getId).collect(Collectors.toList());
+    }
+
 }

+ 4 - 2
src/main/java/cc/uncarbon/module/sys/mapper/xml/SysUserMapper.xml

@@ -5,7 +5,8 @@
     <select id="getUserByPin" resultType="cc.uncarbon.module.sys.entity.SysUserEntity">
         SELECT *
         FROM sys_user
-        WHERE pin = #{pin}
+        WHERE del_flag = 0
+            AND pin = #{pin}
         LIMIT 1
     </select>
 
@@ -17,7 +18,8 @@
                , email
                , phone_no
         FROM sys_user
-        WHERE id = #{userId}
+        WHERE del_flag = 0
+            AND id = #{userId}
         LIMIT 1
     </select>
 

+ 82 - 0
src/main/java/cc/uncarbon/module/sys/model/interior/UserDeptContainer.java

@@ -0,0 +1,82 @@
+package cc.uncarbon.module.sys.model.interior;
+
+import cc.uncarbon.module.sys.entity.SysDeptEntity;
+import cn.hutool.core.collection.CollUtil;
+import lombok.Getter;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 用户关联部门容器
+ */
+@Getter
+public class UserDeptContainer {
+
+    /**
+     * 直接关联的部门IDs
+     * 一般只有0或1个元素
+     */
+    private final List<Long> relatedDeptIds;
+
+    /**
+     * 直接关联的部门实例集合
+     * 一般只有0或1个元素
+     */
+    private final List<SysDeptEntity> relatedDepts;
+
+    /**
+     * 可见的部门IDs
+     */
+    private List<Long> visibleDeptIds;
+
+    /**
+     * 可见的部门实例集合
+     */
+    private List<SysDeptEntity> visibleDepts;
+
+
+    public UserDeptContainer(List<Long> relatedDeptIds, List<SysDeptEntity> relatedDepts) {
+        this.relatedDeptIds = relatedDeptIds;
+        this.relatedDepts = relatedDepts;
+        this.visibleDeptIds = relatedDeptIds;
+        this.visibleDepts = relatedDepts;
+    }
+
+    /**
+     * 用户是否有实际关联的部门
+     */
+    public boolean hasRelatedDepts() {
+        return CollUtil.isNotEmpty(relatedDeptIds) && CollUtil.isNotEmpty(relatedDepts);
+    }
+
+    /**
+     * 用户主要关联的部门,默认取第一个元素
+     * @return null or 部门实例
+     */
+    public SysDeptEntity primaryRelatedDept() {
+        return CollUtil.getFirst(relatedDepts);
+    }
+
+    /**
+     * 用户是否有实际可见的部门
+     * 也可视为部门的数据权限范围
+     */
+    public boolean hasVisibleDepts() {
+        return CollUtil.isNotEmpty(visibleDeptIds) && CollUtil.isNotEmpty(visibleDepts);
+    }
+
+    /**
+     * 更新可见的部门
+     */
+    public void updateVisibleDepts(List<SysDeptEntity> visibleDepts) {
+        if (CollUtil.isEmpty(visibleDepts)) {
+            this.visibleDeptIds = Collections.emptyList();
+            this.visibleDepts = Collections.emptyList();
+        } else {
+            this.visibleDeptIds = visibleDepts.stream().map(SysDeptEntity::getId).collect(Collectors.toList());
+            this.visibleDepts = visibleDepts;
+        }
+    }
+}

+ 48 - 0
src/main/java/cc/uncarbon/module/sys/model/interior/UserRoleContainer.java

@@ -0,0 +1,48 @@
+package cc.uncarbon.module.sys.model.interior;
+
+import cc.uncarbon.module.sys.entity.SysRoleEntity;
+import lombok.Getter;
+
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 用户关联角色容器
+ */
+@Getter
+public class UserRoleContainer {
+
+    /**
+     * 直接关联的角色IDs
+     */
+    private final Set<Long> relatedRoleIds;
+
+    /**
+     * 直接关联的角色实例集合
+     */
+    private final List<SysRoleEntity> relatedRoles;
+
+    /**
+     * 为超级管理员
+     */
+    private final boolean superAdmin;
+
+    /**
+     * 为租户管理员
+     */
+    private final boolean tenantAdmin;
+
+    /**
+     * 非级管理员or租户管理员
+     */
+    private final boolean notAnyAdmin;
+
+
+    public UserRoleContainer(Set<Long> relatedRoleIds, List<SysRoleEntity> relatedRoles) {
+        this.relatedRoleIds = relatedRoleIds;
+        this.relatedRoles = relatedRoles;
+        this.superAdmin = relatedRoles.stream().anyMatch(SysRoleEntity::isSuperAdmin);
+        this.tenantAdmin = relatedRoles.stream().anyMatch(SysRoleEntity::isTenantAdmin);
+        this.notAnyAdmin = !this.superAdmin && !this.tenantAdmin;
+    }
+}

+ 13 - 4
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysDataDictDTO.java

@@ -8,6 +8,7 @@ import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 
 import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
 import java.io.Serializable;
 
 
@@ -25,32 +26,40 @@ public class AdminInsertOrUpdateSysDataDictDTO implements Serializable {
     private Long id;
 
     @ApiModelProperty(value = "驼峰式键名", required = true)
+    @Size(max = 100, message = "【驼峰式键名】最长100位")
     @NotBlank(message = "驼峰式键名不能为空")
     private String camelCaseKey;
 
     @ApiModelProperty(value = "下划线式键名", required = true)
+    @Size(max = 100, message = "【下划线式键名】最长100位")
     @NotBlank(message = "下划线式键名不能为空")
     private String underCaseKey;
 
     @ApiModelProperty(value = "帕斯卡式键名", required = true)
+    @Size(max = 100, message = "【帕斯卡式键名】最长100位")
     @NotBlank(message = "帕斯卡式键名不能为空")
     private String pascalCaseKey;
 
-    @ApiModelProperty(value = "键值", required = true)
-    @NotBlank(message = "键值不能为空")
+    @ApiModelProperty(value = "数据值", required = true)
+    @Size(max = 255, message = "【数据值】最长255位")
+    @NotBlank(message = "数据值不能为空")
     private String value;
 
-    @ApiModelProperty(value = "参数描述", required = true)
-    @NotBlank(message = "参数描述不能为空")
+    @ApiModelProperty(value = "描述", required = true)
+    @Size(max = 255, message = "【描述】最长255位")
+    @NotBlank(message = "描述不能为空")
     private String description;
 
     @ApiModelProperty(value = "单位")
+    @Size(max = 30, message = "【单位】最长30位")
     private String unit;
 
     @ApiModelProperty(value = "取值范围")
+    @Size(max = 255, message = "【取值范围】最长255位")
     private String valueRange;
 
     @ApiModelProperty(value = "别称键名")
+    @Size(max = 100, message = "【别称键名】最长100位")
     private String aliasKey;
 
 }

+ 4 - 5
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysDeptDTO.java

@@ -9,6 +9,7 @@ import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 
 import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
 import java.io.Serializable;
 
 
@@ -25,11 +26,9 @@ public class AdminInsertOrUpdateSysDeptDTO implements Serializable {
     @ApiModelProperty(value = "主键ID", hidden = true, notes = "仅更新时使用")
     private Long id;
 
-    @ApiModelProperty(value = "所属租户ID", hidden = true, notes = "仅新增时使用")
-    private Long tenantId;
-
-    @ApiModelProperty(value = "名称", required = true)
-    @NotBlank(message = "名称不能为空")
+    @ApiModelProperty(value = "部门名称", required = true)
+    @Size(max = 50, message = "【部门名称】最长50位")
+    @NotBlank(message = "部门名称不能为空")
     private String title;
 
     @ApiModelProperty(value = "上级ID(无上级节点设置为0)")

+ 8 - 5
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysMenuDTO.java

@@ -11,6 +11,7 @@ import lombok.experimental.Accessors;
 
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
 import java.io.Serializable;
 
 
@@ -27,11 +28,9 @@ public class AdminInsertOrUpdateSysMenuDTO implements Serializable {
     @ApiModelProperty(value = "主键ID", hidden = true, notes = "仅更新时使用")
     private Long id;
 
-    @ApiModelProperty(value = "所属租户ID", hidden = true, notes = "仅新增时使用")
-    private Long tenantId;
-
-    @ApiModelProperty(value = "名称", required = true)
-    @NotBlank(message = "名称不能为空")
+    @ApiModelProperty(value = "菜单名称", required = true)
+    @Size(max = 50, message = "【菜单名称】最长50位")
+    @NotBlank(message = "菜单名称不能为空")
     private String title;
 
     @ApiModelProperty(value = "上级菜单ID(无上级节点设置为0)")
@@ -42,12 +41,15 @@ public class AdminInsertOrUpdateSysMenuDTO implements Serializable {
     private SysMenuTypeEnum type;
 
     @ApiModelProperty(value = "组件")
+    @Size(max = 50, message = "【组件】最长50位")
     private String component;
 
     @ApiModelProperty(value = "权限标识")
+    @Size(max = 255, message = "【权限标识】最长255位")
     private String permission;
 
     @ApiModelProperty(value = "图标")
+    @Size(max = 255, message = "【图标】最长255位")
     private String icon;
 
     @ApiModelProperty(value = "排序")
@@ -57,6 +59,7 @@ public class AdminInsertOrUpdateSysMenuDTO implements Serializable {
     private EnabledStatusEnum status;
 
     @ApiModelProperty(value = "外链地址")
+    @Size(max = 255, message = "【外链地址】最长255位")
     private String externalLink;
 
 }

+ 4 - 0
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysParamDTO.java

@@ -8,6 +8,7 @@ import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 
 import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
 import java.io.Serializable;
 
 
@@ -25,14 +26,17 @@ public class AdminInsertOrUpdateSysParamDTO implements Serializable {
     private Long id;
 
     @ApiModelProperty(value = "键名", required = true)
+    @Size(max = 50, message = "【键名】最长50位")
     @NotBlank(message = "键名不能为空")
     private String name;
 
     @ApiModelProperty(value = "键值", required = true)
+    @Size(max = 255, message = "【键值】最长255位")
     @NotBlank(message = "键值不能为空")
     private String value;
 
     @ApiModelProperty(value = "描述", required = true)
+    @Size(max = 255, message = "【描述】最长255位")
     @NotBlank(message = "描述不能为空")
     private String description;
 

+ 19 - 6
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysRoleDTO.java

@@ -1,14 +1,18 @@
 package cc.uncarbon.module.sys.model.request;
 
+import cc.uncarbon.module.sys.constant.SysConstant;
 import io.swagger.annotations.ApiModelProperty;
-import java.io.Serializable;
-import javax.validation.constraints.NotBlank;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.Size;
+import java.io.Serializable;
+import java.util.Objects;
+
 
 /**
  * 后台管理-新增/编辑后台角色
@@ -26,12 +30,21 @@ public class AdminInsertOrUpdateSysRoleDTO implements Serializable {
     @ApiModelProperty(value = "所属租户ID", hidden = true, notes = "仅新增时使用")
     private Long tenantId;
 
-    @ApiModelProperty(value = "名称", required = true)
-    @NotBlank(message = "名称不能为空")
+    @ApiModelProperty(value = "角色名", required = true)
+    @Size(max = 50, message = "【角色名】最长50位")
+    @NotBlank(message = "角色名不能为空")
     private String title;
 
-    @ApiModelProperty(value = "值", required = true)
-    @NotBlank(message = "值不能为空")
+    @ApiModelProperty(value = "角色编码", required = true)
+    @Size(max = 100, message = "【角色编码】最长100位")
+    @NotBlank(message = "角色编码不能为空")
     private String value;
 
+    /**
+     * 是否用于创建新租户管理员角色
+     */
+    public boolean creatingNewTenantAdmin() {
+        return Objects.nonNull(tenantId) && SysConstant.TENANT_ADMIN_ROLE_VALUE.equalsIgnoreCase(value);
+    }
+
 }

+ 20 - 0
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertOrUpdateSysUserDTO.java

@@ -2,7 +2,9 @@ package cc.uncarbon.module.sys.model.request;
 
 import cc.uncarbon.framework.core.constant.HelioConstant;
 import cc.uncarbon.framework.core.enums.GenderEnum;
+import cc.uncarbon.framework.core.exception.BusinessException;
 import cc.uncarbon.module.sys.enums.SysUserStatusEnum;
+import cn.hutool.core.text.CharSequenceUtil;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
@@ -13,7 +15,9 @@ import lombok.experimental.Accessors;
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
 import javax.validation.constraints.Pattern;
+import javax.validation.constraints.Size;
 import java.io.Serializable;
+import java.util.Objects;
 
 
 /**
@@ -33,6 +37,7 @@ public class AdminInsertOrUpdateSysUserDTO implements Serializable {
     private Long tenantId;
 
     @ApiModelProperty(value = "账号", required = true)
+    @Size(min = 6, max = 16, message = "【账号】最短6位,最长16位")
     @NotBlank(message = "账号不能为空")
     private String username;
 
@@ -40,6 +45,7 @@ public class AdminInsertOrUpdateSysUserDTO implements Serializable {
     private String passwordOfNewUser;
 
     @ApiModelProperty(value = "昵称", required = true)
+    @Size(max = 100, message = "【昵称】最长100位")
     @NotBlank(message = "昵称不能为空")
     private String nickname;
 
@@ -53,15 +59,29 @@ public class AdminInsertOrUpdateSysUserDTO implements Serializable {
 
     @ApiModelProperty(value = "邮箱", required = true)
     @Pattern(message = "邮箱格式有误", regexp = HelioConstant.Regex.EMAIL)
+    @Size(max = 255, message = "【邮箱】最长255位")
     @NotBlank(message = "邮箱不能为空")
     private String email;
 
     @ApiModelProperty(value = "手机号", required = true)
     @Pattern(message = "手机号格式有误", regexp = HelioConstant.Regex.CHINA_MAINLAND_PHONE_NO)
+    @Size(max = 20, message = "【手机号】最长20位")
     @NotBlank(message = "手机号不能为空")
     private String phoneNo;
 
     @ApiModelProperty(value = "所属部门ID")
     private Long deptId;
 
+
+    public void validate() {
+        boolean isUpdate = Objects.nonNull(id);
+        if (!isUpdate) {
+            // 新增
+            int passwordOfNewUserLen = CharSequenceUtil.length(passwordOfNewUser);
+            if (passwordOfNewUserLen < 8 || passwordOfNewUserLen > 20) {
+                throw new BusinessException("【密码】最短8位,最长20位");
+            }
+        }
+    }
+
 }

+ 12 - 4
src/main/java/cc/uncarbon/module/sys/model/request/AdminInsertSysTenantDTO.java

@@ -8,8 +8,7 @@ import lombok.EqualsAndHashCode;
 import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 
-import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.Pattern;
+import javax.validation.constraints.*;
 
 
 /**
@@ -22,21 +21,30 @@ import javax.validation.constraints.Pattern;
 @Data
 public class AdminInsertSysTenantDTO extends AdminUpdateSysTenantDTO {
 
+    @ApiModelProperty(value = "租户ID(纯数字)", required = true)
+    @Positive(message = "租户ID须为正整数")
+    @NotNull(message = "租户ID不能为空")
+    private Long tenantId;
+
     @ApiModelProperty(value = "管理员账号", required = true)
+    @Size(min = 6, max = 16, message = "【管理员账号】最短6位,最长16位")
     @NotBlank(message = "管理员账号不能为空")
     private String tenantAdminUsername;
 
     @ApiModelProperty(value = "管理员密码", required = true)
+    @Size(min = 8, max = 20, message = "【管理员密码】最短8位,最长20位")
     @NotBlank(message = "管理员密码不能为空")
     private String tenantAdminPassword;
 
     @ApiModelProperty(value = "管理员邮箱", required = true)
-    @Pattern(message = "邮箱格式不正确", regexp = HelioConstant.Regex.EMAIL)
+    @Pattern(message = "管理员邮箱格式不正确", regexp = HelioConstant.Regex.EMAIL)
+    @Size(max = 255, message = "【管理员邮箱】最长255位")
     @NotBlank(message = "管理员邮箱不能为空")
     private String tenantAdminEmail;
 
     @ApiModelProperty(value = "管理员手机号", required = true)
-    @Pattern(message = "手机号格式不正确", regexp = HelioConstant.Regex.CHINA_MAINLAND_PHONE_NO)
+    @Pattern(message = "管理员手机号格式不正确", regexp = HelioConstant.Regex.CHINA_MAINLAND_PHONE_NO)
+    @Size(max = 20, message = "【管理员手机号】最长20位")
     @NotBlank(message = "管理员手机号不能为空")
     private String tenantAdminPhoneNo;
 

+ 1 - 1
src/main/java/cc/uncarbon/module/sys/model/request/AdminListSysDataDictDTO.java

@@ -20,7 +20,7 @@ import java.io.Serializable;
 @Data
 public class AdminListSysDataDictDTO implements Serializable {
 
-    @ApiModelProperty(value = "参数描述(关键词)")
+    @ApiModelProperty(value = "描述(关键词)")
     private String description;
 
 }

+ 2 - 2
src/main/java/cc/uncarbon/module/sys/model/request/AdminListSysRoleDTO.java

@@ -20,10 +20,10 @@ import java.io.Serializable;
 @Data
 public class AdminListSysRoleDTO implements Serializable {
 
-    @ApiModelProperty(value = "名(关键词)")
+    @ApiModelProperty(value = "角色名(关键词)")
     private String title;
 
-    @ApiModelProperty(value = "(关键词)")
+    @ApiModelProperty(value = "角色编码(关键词)")
     private String value;
 
 }

+ 12 - 0
src/main/java/cc/uncarbon/module/sys/model/request/AdminListSysUserDTO.java

@@ -1,5 +1,6 @@
 package cc.uncarbon.module.sys.model.request;
 
+import cc.uncarbon.module.sys.constant.SysConstant;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.AllArgsConstructor;
 import lombok.Builder;
@@ -8,6 +9,7 @@ import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 
 import java.io.Serializable;
+import java.util.Objects;
 
 
 /**
@@ -23,4 +25,14 @@ public class AdminListSysUserDTO implements Serializable {
     @ApiModelProperty(value = "手机号(关键词)")
     private String phoneNo;
 
+    @ApiModelProperty(value = "手动选择的部门ID")
+    private Long selectedDeptId;
+
+    /**
+     * 是否需要根据【手动选择的部门】筛选用户
+     */
+    public boolean needFilterBySelectedDeptId() {
+        return Objects.nonNull(selectedDeptId) && selectedDeptId > SysConstant.ROOT_PARENT_ID;
+    }
+
 }

+ 2 - 2
src/main/java/cc/uncarbon/module/sys/model/request/AdminResetSysUserPasswordDTO.java

@@ -23,11 +23,11 @@ import java.io.Serializable;
 public class AdminResetSysUserPasswordDTO implements Serializable {
 
     @ApiModelProperty(value = "随机新密码", required = true)
-    @Size(min = 16, max = 64, message = "随机新密码须为16-64位")
+    @Size(min = 16, max = 64, message = "【随机新密码】最短16位,最长64位")
     @NotBlank(message = "随机新密码不能为空")
     private String randomPassword;
 
-    @ApiModelProperty(value = "用户ID", hidden  = true)
+    @ApiModelProperty(value = "用户ID", hidden = true)
     private Long userId;
 
 }

+ 2 - 2
src/main/java/cc/uncarbon/module/sys/model/request/AdminUpdateCurrentSysUserPasswordDTO.java

@@ -27,12 +27,12 @@ public class AdminUpdateCurrentSysUserPasswordDTO implements Serializable {
     private String oldPassword;
 
     @ApiModelProperty(value = "新密码", required = true)
-    @Size(min = 8, max = 20, message = "密码须为8-20位")
+    @Size(min = 8, max = 20, message = "【密码】长度须在 8 至 20 位之间")
     @NotBlank(message = "密码不能为空")
     private String newPassword;
 
     @ApiModelProperty(value = "确认新密码", required = true)
-    @Size(min = 8, max = 20, message = "确认密码须为8-20位")
+    @Size(min = 8, max = 20, message = "【确认密码】长度须在 8 至 20 位之间")
     @NotBlank(message = "确认密码不能为空")
     private String confirmNewPassword;
 

+ 3 - 4
src/main/java/cc/uncarbon/module/sys/model/request/AdminUpdateSysTenantDTO.java

@@ -10,6 +10,7 @@ import lombok.experimental.Accessors;
 
 import javax.validation.constraints.NotBlank;
 import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
 import java.io.Serializable;
 
 
@@ -27,18 +28,16 @@ public class AdminUpdateSysTenantDTO implements Serializable {
     private Long id;
 
     @ApiModelProperty(value = "租户名", required = true)
+    @Size(max = 50, message = "【租户名】最长50位")
     @NotBlank(message = "租户名不能为空")
     private String tenantName;
 
-    @ApiModelProperty(value = "租户ID(纯数字)", required = true)
-    @NotNull(message = "租户ID不能为空")
-    private Long tenantId;
-
     @ApiModelProperty(value = "状态", required = true)
     @NotNull(message = "状态不能为空")
     private EnabledStatusEnum status;
 
     @ApiModelProperty(value = "备注")
+    @Size(max = 255, message = "【备注】最长255位")
     private String remark;
 
 }

+ 5 - 5
src/main/java/cc/uncarbon/module/sys/model/request/SysUserLoginDTO.java

@@ -24,12 +24,12 @@ import java.io.Serializable;
 public class SysUserLoginDTO implements Serializable {
 
     @ApiModelProperty(value = "账号", required = true)
-    @Size(min = 5, max = 16, message = "【账号】长度须在 5 至 16 之间")
+    @Size(min = 5, max = 16, message = "【账号】最短5位,最长16位")
     @NotBlank(message = "账号不能为空")
     private String username;
 
     @ApiModelProperty(value = "密码", required = true)
-    @Size(min = 5, max = 64, message = "【密码】长度须在 5 至 16 位之间")
+    @Size(min = 5, max = 20, message = "【密码】最短5位,最长20位")
     @NotBlank(message = "密码不能为空")
     private String password;
 
@@ -40,10 +40,10 @@ public class SysUserLoginDTO implements Serializable {
     @ApiModelProperty(value = "租户ID(可选,启用多租户后有效)")
     private Long tenantId;
 
-    @ApiModelProperty(value = "验证码图片UUID(可选,需自行对接业务逻辑)")
-    private String captchaUUID;
+    @ApiModelProperty(value = "验证码唯一标识(可选)")
+    private String captchaId;
 
-    @ApiModelProperty(value = "验证码答案(可选,需自行对接业务逻辑)")
+    @ApiModelProperty(value = "验证码答案(可选)")
     private String captchaAnswer;
 
 }

+ 2 - 2
src/main/java/cc/uncarbon/module/sys/model/response/SysDataDictBO.java

@@ -46,10 +46,10 @@ public class SysDataDictBO implements Serializable {
     @ApiModelProperty(value = "帕斯卡式键名")
     private String pascalCaseKey;
 
-    @ApiModelProperty(value = "值")
+    @ApiModelProperty(value = "数据值")
     private String value;
 
-    @ApiModelProperty(value = "参数描述")
+    @ApiModelProperty(value = "描述")
     private String description;
 
     @ApiModelProperty(value = "单位")

+ 2 - 2
src/main/java/cc/uncarbon/module/sys/model/response/SysRoleBO.java

@@ -38,10 +38,10 @@ public class SysRoleBO implements Serializable {
     @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = HelioConstant.Jackson.DATE_TIME_FORMAT)
     private LocalDateTime updatedAt;
 
-    @ApiModelProperty(value = "名")
+    @ApiModelProperty(value = "角色名")
     private String title;
 
-    @ApiModelProperty(value = "")
+    @ApiModelProperty(value = "角色编码")
     private String value;
 
     @ApiModelProperty(value = "可见菜单Ids")

+ 26 - 0
src/main/java/cc/uncarbon/module/sys/model/response/SysTenantKickOutUsersBO.java

@@ -0,0 +1,26 @@
+package cc.uncarbon.module.sys.model.response;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Getter;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 系统租户-需强制登出用户 BO
+ * 同一时间大量登出,会操作大量Redis键,可能存在缓存雪崩的风险
+ */
+@Getter
+public class SysTenantKickOutUsersBO {
+
+    @ApiModelProperty(value = "后台用户IDs")
+    private final List<Long> sysUserIds;
+
+    public SysTenantKickOutUsersBO() {
+        this.sysUserIds = Collections.emptyList();
+    }
+
+    public SysTenantKickOutUsersBO(List<Long> sysUserIds) {
+        this.sysUserIds = sysUserIds;
+    }
+}

+ 5 - 4
src/main/java/cc/uncarbon/module/sys/model/response/SysUserLoginBO.java

@@ -2,16 +2,17 @@ package cc.uncarbon.module.sys.model.response;
 
 import cc.uncarbon.framework.core.context.TenantContext;
 import io.swagger.annotations.ApiModelProperty;
-import java.io.Serializable;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 import lombok.experimental.SuperBuilder;
 
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 
 /**
  * 登录后返回的字段

+ 3 - 2
src/main/java/cc/uncarbon/module/sys/model/response/SysUserLoginVO.java

@@ -1,14 +1,15 @@
 package cc.uncarbon.module.sys.model.response;
 
 import io.swagger.annotations.ApiModelProperty;
-import java.io.Serializable;
-import java.util.Collection;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.experimental.Accessors;
 import lombok.experimental.SuperBuilder;
 
+import java.io.Serializable;
+import java.util.Collection;
+
 
 /**
  * 登录后返回的字段

+ 1 - 2
src/main/java/cc/uncarbon/module/sys/service/SysDataDictService.java

@@ -176,8 +176,7 @@ public class SysDataDictService {
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                .setRecords(this.entityList2BOs(entityPage.getRecords()));
     }
 
     /**

+ 79 - 22
src/main/java/cc/uncarbon/module/sys/service/SysDeptService.java

@@ -1,11 +1,14 @@
 package cc.uncarbon.module.sys.service;
 
 import cc.uncarbon.framework.core.constant.HelioConstant;
+import cc.uncarbon.framework.core.context.UserContextHolder;
 import cc.uncarbon.framework.core.exception.BusinessException;
 import cc.uncarbon.module.sys.constant.SysConstant;
 import cc.uncarbon.module.sys.entity.SysDeptEntity;
 import cc.uncarbon.module.sys.enums.SysErrorEnum;
 import cc.uncarbon.module.sys.mapper.SysDeptMapper;
+import cc.uncarbon.module.sys.model.interior.UserDeptContainer;
+import cc.uncarbon.module.sys.model.interior.UserRoleContainer;
 import cc.uncarbon.module.sys.model.request.AdminInsertOrUpdateSysDeptDTO;
 import cc.uncarbon.module.sys.model.response.SysDeptBO;
 import cn.hutool.core.bean.BeanUtil;
@@ -17,10 +20,8 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
+import java.util.*;
+import java.util.stream.Collectors;
 
 
 /**
@@ -33,20 +34,14 @@ public class SysDeptService {
 
     private final SysDeptMapper sysDeptMapper;
     private final SysUserDeptRelationService sysUserDeptRelationService;
+    private final SysRoleService sysRoleService;
 
 
     /**
      * 后台管理-列表
      */
     public List<SysDeptBO> adminList() {
-        List<SysDeptEntity> entityList = sysDeptMapper.selectList(
-                new QueryWrapper<SysDeptEntity>()
-                        .lambda()
-                        // 排序
-                        .orderByAsc(SysDeptEntity::getSort)
-        );
-
-        return this.entityList2BOs(entityList);
+        return entityList2BOs(sysDeptMapper.sortedList());
     }
 
     /**
@@ -62,7 +57,7 @@ public class SysDeptService {
     /**
      * 根据 ID 取详情
      *
-     * @param id 主键ID
+     * @param id               主键ID
      * @param throwIfInvalidId 是否在 ID 无效时抛出异常
      * @return null or BO
      */
@@ -124,19 +119,51 @@ public class SysDeptService {
     }
 
     /**
-     * 取所属部门简易信息
-     *
-     * @param userId 用户ID
+     * 后台管理-下拉框数据
+     * @param inferiorsOnly 只能看到本部门及以下
      */
-    public SysDeptBO getPlainDeptByUserId(Long userId) {
-        List<Long> deptIds = sysUserDeptRelationService.getUserDeptIds(userId);
-        if (CollUtil.isEmpty(deptIds)) {
-            return null;
+    public List<SysDeptBO> adminSelectOptions(boolean inferiorsOnly) {
+        if (inferiorsOnly) {
+            UserRoleContainer currentUser = sysRoleService.getCurrentUserRoleContainer();
+            if (currentUser.isNotAnyAdmin()) {
+                // 非管理员才会限制,只能看到本部门及以下
+                UserDeptContainer deptContainer = getCurrentUserDeptContainer(true);
+                return entityList2BOs(deptContainer.getVisibleDepts());
+            }
         }
-        SysDeptEntity entity = sysDeptMapper.selectById(CollUtil.getFirst(deptIds));
-        return this.entity2BO(entity);
+        // 能看所有
+        return adminList();
     }
 
+    /**
+     * 取当前用户关联部门信息
+     * 仅内部使用
+     * @param queryVisibleDept 是否要进一步查询可见部门
+     */
+    protected UserDeptContainer getCurrentUserDeptContainer(boolean queryVisibleDept) {
+        return getSpecifiedUserDeptContainer(UserContextHolder.getUserId(), queryVisibleDept);
+    }
+
+    /**
+     * 取指定用户关联部门信息
+     * 仅内部使用
+     * @param queryVisibleDept 是否要进一步查询可见部门
+     */
+    protected UserDeptContainer getSpecifiedUserDeptContainer(Long specifiedUserId, boolean queryVisibleDept) {
+        List<Long> currentUserDeptIds = sysUserDeptRelationService.getUserDeptIds(specifiedUserId);
+        List<SysDeptEntity> currentUserDepts = Collections.emptyList();
+        if (CollUtil.isNotEmpty(currentUserDeptIds)) {
+            currentUserDepts = sysDeptMapper.selectBatchIds(currentUserDeptIds);
+        }
+        UserDeptContainer container = new UserDeptContainer(currentUserDeptIds, currentUserDepts);
+
+        if (queryVisibleDept && container.hasRelatedDepts()) {
+            List<SysDeptEntity> allDepts = sysDeptMapper.sortedList();
+            List<SysDeptEntity> inferiors = determineAllInferiors(allDepts, container.primaryRelatedDept());
+            container.updateVisibleDepts(inferiors);
+        }
+        return container;
+    }
 
     /*
     ----------------------------------------------------------------
@@ -207,4 +234,34 @@ public class SysDeptService {
             throw new BusinessException(400, "已存在相同部门,请重新输入");
         }
     }
+
+    /**
+     * 找出本部门及所有下级部门
+     *
+     * @param start 本部门
+     * @return 本部门 + 所有下级部门
+     */
+    private List<SysDeptEntity> determineAllInferiors(List<SysDeptEntity> entityList, SysDeptEntity start) {
+        // 结果集合
+        List<SysDeptEntity> ret = new ArrayList<>(entityList.size());
+        Deque<SysDeptEntity> deque = new ArrayDeque<>();
+
+        // 转map提高效率
+        Map<Long, List<SysDeptEntity>> groupByParentId = entityList.stream().collect(Collectors.groupingBy(SysDeptEntity::getParentId));
+
+        // 起点
+        ret.add(start);
+        deque.add(start);
+
+        // 循环填充下级部门实例
+        while (CollUtil.isNotEmpty(deque)) {
+            SysDeptEntity parent = deque.pop();
+            List<SysDeptEntity> children = groupByParentId.get(parent.getId());
+            if (CollUtil.isNotEmpty(children)) {
+                ret.addAll(children);
+                deque.addAll(children);
+            }
+        }
+        return ret;
+    }
 }

+ 1 - 2
src/main/java/cc/uncarbon/module/sys/service/SysLogService.java

@@ -173,8 +173,7 @@ public class SysLogService {
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                .setRecords(this.entityList2BOs(entityPage.getRecords()));
     }
 
 }

+ 8 - 15
src/main/java/cc/uncarbon/module/sys/service/SysMenuService.java

@@ -174,8 +174,7 @@ public class SysMenuService {
                         permissions = sysMenuMapper.selectList(null).stream()
                                 .map(SysMenuEntity::getPermission)
                                 .filter(StrUtil::isNotEmpty)
-                                .collect(Collectors.toSet())
-                        ;
+                                .collect(Collectors.toSet());
                     } else {
                         // 非超级管理员则通过角色ID,关联查询拥有的菜单,菜单上有权限名
 
@@ -254,34 +253,28 @@ public class SysMenuService {
         // 这里是兼容 JDK8 的写法,使用较高 JDK 版本可使用语法糖
         switch (bo.getType()) {
             case DIR:
-            case BUTTON: {
+            case BUTTON:
                 bo
                         .setComponent(SysConstant.VBEN_ADMIN_BLANK_VIEW)
                         .setExternalLink(null)
-                        .setPath(StrPool.SLASH + snowflakeIdStr)
-                ;
+                        .setPath(StrPool.SLASH + snowflakeIdStr);
                 break;
-            }
-            case MENU: {
+            case MENU:
                 bo
                         .setExternalLink(null)
-                        .setPath(bo.getComponent())
-                ;
+                        .setPath(bo.getComponent());
                 // 防止用户忘记加了, 主动补充/
                 if (CharSequenceUtil.isNotBlank(bo.getPath()) && !bo.getPath().startsWith(StrPool.SLASH)) {
                     bo.setPath(StrPool.SLASH + bo.getPath());
                 }
                 break;
-            }
-            case EXTERNAL_LINK: {
+            case EXTERNAL_LINK:
                 bo
                         .setComponent(bo.getExternalLink())
-                        .setPath(bo.getExternalLink())
-                ;
+                        .setPath(bo.getExternalLink());
                 break;
-            }
+            default: break;
         }
-
         return bo;
     }
 

+ 1 - 2
src/main/java/cc/uncarbon/module/sys/service/SysParamService.java

@@ -216,8 +216,7 @@ public class SysParamService {
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                .setRecords(this.entityList2BOs(entityPage.getRecords()));
     }
 
     /**

+ 14 - 23
src/main/java/cc/uncarbon/module/sys/service/SysRoleMenuRelationService.java

@@ -71,9 +71,7 @@ public class SysRoleMenuRelationService {
             return;
         }
 
-        /*
-        先删除不再需要的关联关系
-         */
+        // 先删除不再需要的关联关系
         sysRoleMenuRelationMapper.delete(
                 new QueryWrapper<SysRoleMenuRelationEntity>()
                         .lambda()
@@ -81,31 +79,24 @@ public class SysRoleMenuRelationService {
                         .notIn(SysRoleMenuRelationEntity::getMenuId, menuIds)
         );
 
-        /*
-        取出需要增量更新的部分
-         */
+        // 取出需要增量更新的部分
         Set<Long> existingMenuIds = sysRoleMenuRelationMapper.selectList(menuIdsQuery)
                 .stream().map(SysRoleMenuRelationEntity::getMenuId)
                 .collect(Collectors.toSet());
         menuIds.removeAll(existingMenuIds);
 
-        if (CollUtil.isEmpty(menuIds)) {
-            // 没有需要增量更新的部分
-            return;
-        }
-
-        /*
-        批量插入需要增量更新的部分
-         */
-        List<SysRoleMenuRelationEntity> entityList = new ArrayList<>(menuIds.size());
-        for (Long menuId : menuIds) {
-            entityList.add(
-                    SysRoleMenuRelationEntity.builder()
-                            .roleId(roleId)
-                            .menuId(menuId)
-                            .build()
-            );
+        if (CollUtil.isNotEmpty(menuIds)) {
+            // 批量插入需要增量更新的部分
+            List<SysRoleMenuRelationEntity> entityList = new ArrayList<>(menuIds.size());
+            for (Long menuId : menuIds) {
+                entityList.add(
+                        SysRoleMenuRelationEntity.builder()
+                                .roleId(roleId)
+                                .menuId(menuId)
+                                .build()
+                );
+            }
+            entityList.forEach(sysRoleMenuRelationMapper::insert);
         }
-        entityList.forEach(sysRoleMenuRelationMapper::insert);
     }
 }

+ 224 - 24
src/main/java/cc/uncarbon/module/sys/service/SysRoleService.java

@@ -1,13 +1,16 @@
 package cc.uncarbon.module.sys.service;
 
 import cc.uncarbon.framework.core.constant.HelioConstant;
+import cc.uncarbon.framework.core.context.UserContextHolder;
 import cc.uncarbon.framework.core.exception.BusinessException;
 import cc.uncarbon.framework.core.function.StreamFunction;
 import cc.uncarbon.framework.core.page.PageParam;
 import cc.uncarbon.framework.core.page.PageResult;
+import cc.uncarbon.module.sys.constant.SysConstant;
 import cc.uncarbon.module.sys.entity.SysRoleEntity;
 import cc.uncarbon.module.sys.enums.SysErrorEnum;
 import cc.uncarbon.module.sys.mapper.SysRoleMapper;
+import cc.uncarbon.module.sys.model.interior.UserRoleContainer;
 import cc.uncarbon.module.sys.model.request.AdminBindRoleMenuRelationDTO;
 import cc.uncarbon.module.sys.model.request.AdminInsertOrUpdateSysRoleDTO;
 import cc.uncarbon.module.sys.model.request.AdminListSysRoleDTO;
@@ -44,6 +47,7 @@ public class SysRoleService {
      * 后台管理-分页列表
      */
     public PageResult<SysRoleBO> adminList(PageParam pageParam, AdminListSysRoleDTO dto) {
+        Set<Long> invisibleRoleIds = determineInvisibleRoleIds();
         Page<SysRoleEntity> entityPage = sysRoleMapper.selectPage(
                 new Page<>(pageParam.getPageNum(), pageParam.getPageSize()),
                 new QueryWrapper<SysRoleEntity>()
@@ -52,11 +56,13 @@ public class SysRoleService {
                         .like(CharSequenceUtil.isNotBlank(dto.getTitle()), SysRoleEntity::getTitle, CharSequenceUtil.cleanBlank(dto.getTitle()))
                         // 值
                         .like(CharSequenceUtil.isNotBlank(dto.getValue()), SysRoleEntity::getValue, CharSequenceUtil.cleanBlank(dto.getValue()))
+                        // 不显示特定角色
+                        .notIn(CollUtil.isNotEmpty(invisibleRoleIds), SysRoleEntity::getId, invisibleRoleIds)
                         // 排序
                         .orderByDesc(SysRoleEntity::getCreatedAt)
         );
 
-        return this.entityPage2BOPage(entityPage);
+        return this.entityPage2BOPage(entityPage, true);
     }
 
     /**
@@ -82,7 +88,7 @@ public class SysRoleService {
             SysErrorEnum.INVALID_ID.assertNotNull(entity);
         }
 
-        return this.entity2BO(entity);
+        return this.entity2BO(entity, true);
     }
 
     /**
@@ -93,6 +99,7 @@ public class SysRoleService {
     @Transactional(rollbackFor = Exception.class)
     public Long adminInsert(AdminInsertOrUpdateSysRoleDTO dto) {
         log.info("[后台管理-新增后台角色] >> 入参={}", dto);
+        preInsertOrUpdateCheck(dto);
         this.checkExistence(dto);
 
         dto.setId(null);
@@ -110,8 +117,11 @@ public class SysRoleService {
     @Transactional(rollbackFor = Exception.class)
     public void adminUpdate(AdminInsertOrUpdateSysRoleDTO dto) {
         log.info("[后台管理-编辑后台角色] >> 入参={}", dto);
+        preInsertOrUpdateCheck(dto);
         this.checkExistence(dto);
 
+        // 暂不检查该角色是否为当前用户关联的角色
+
         SysRoleEntity entity = new SysRoleEntity();
         BeanUtil.copyProperties(dto, entity);
 
@@ -124,6 +134,7 @@ public class SysRoleService {
     @Transactional(rollbackFor = Exception.class)
     public void adminDelete(Collection<Long> ids) {
         log.info("[后台管理-删除后台角色] >> 入参={}", ids);
+        preDeleteCheck(ids);
         sysRoleMapper.deleteBatchIds(ids);
     }
 
@@ -134,12 +145,125 @@ public class SysRoleService {
      */
     @Transactional(rollbackFor = Exception.class)
     public Set<String> adminBindMenus(AdminBindRoleMenuRelationDTO dto) {
+        preBindRoleMenuRelationCheck(dto);
         Set<String> newPermissions = sysMenuService.listPermissionsByMenuIds(dto.getMenuIds());
         sysRoleMenuRelationService.cleanAndBind(dto.getRoleId(), dto.getMenuIds());
 
         return newPermissions;
     }
 
+    /**
+     * 后台管理-下拉框数据
+     */
+    public List<SysRoleBO> adminSelectOptions() {
+        Set<Long> invisibleRoleIds = determineInvisibleRoleIds();
+        List<SysRoleEntity> entityList = sysRoleMapper.selectList(
+                new QueryWrapper<SysRoleEntity>()
+                        .lambda()
+                        // 只取特定字段
+                        .select(SysRoleEntity::getId, SysRoleEntity::getTitle)
+                        // 不显示特定角色
+                        .notIn(CollUtil.isNotEmpty(invisibleRoleIds), SysRoleEntity::getId, invisibleRoleIds)
+                        // 排序
+                        .orderByAsc(SysRoleEntity::getId)
+        );
+        // 无需填充菜单IDs
+        return entityList2BOs(entityList, false);
+    }
+
+    /**
+     * 后台管理-删除指定租户的特定角色
+     * @param tenantIds 租户IDs,非主键ID,不能为空
+     * @param roleValues 角色值集合,可以为空
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public void adminDeleteTenantRoles(Collection<Long> tenantIds, Collection<String> roleValues) {
+        if (CollUtil.isEmpty(tenantIds)) {
+            return;
+        }
+
+        sysRoleMapper.delete(
+                new QueryWrapper<SysRoleEntity>()
+                        .lambda()
+                        // 租户ID
+                        .in(SysRoleEntity::getTenantId, tenantIds)
+                        // 值相符
+                        .in(CollUtil.isNotEmpty(roleValues), SysRoleEntity::getValue, roleValues)
+        );
+    }
+
+    /**
+     * 取用户ID拥有角色对应的 角色ID-角色名 map
+     *
+     * @param userId 用户ID
+     * @return 失败返回空 map
+     */
+    public Map<Long, String> getRoleMapByUserId(Long userId) {
+        Set<Long> roleIds = sysUserRoleRelationService.listRoleIdsByUserId(userId);
+
+        if (CollUtil.isEmpty(roleIds)) {
+            return Collections.emptyMap();
+        }
+
+        // 根据角色Ids取 map
+        return sysRoleMapper.selectList(
+                new QueryWrapper<SysRoleEntity>()
+                        .lambda()
+                        .select(SysRoleEntity::getId, SysRoleEntity::getValue)
+                        .in(SysRoleEntity::getId, roleIds)
+        ).stream().collect(Collectors.toMap(SysRoleEntity::getId, SysRoleEntity::getValue, StreamFunction.ignoredThrowingMerger()));
+    }
+
+    /**
+     * 取当前用户关联角色信息
+     * 仅内部使用
+     */
+    protected UserRoleContainer getCurrentUserRoleContainer() {
+        return getSpecifiedUserRoleContainer(UserContextHolder.getUserId());
+    }
+
+    /**
+     * 取指定用户关联角色信息
+     * 仅内部使用
+     */
+    protected UserRoleContainer getSpecifiedUserRoleContainer(Long specifiedUserId) {
+        Set<Long> currentUserRoleIds = sysUserRoleRelationService.listRoleIdsByUserId(specifiedUserId);
+        List<SysRoleEntity> currentUserRoles = Collections.emptyList();
+        if (CollUtil.isNotEmpty(currentUserRoleIds)) {
+            currentUserRoles = sysRoleMapper.selectBatchIds(currentUserRoleIds);
+        }
+        return new UserRoleContainer(currentUserRoleIds, currentUserRoles);
+    }
+
+    /**
+     * 确定不可见角色IDs
+     * 仅内部使用
+     * 租户管理员:列表中不显示超级管理员角色
+     * 普通角色:列表中不显示超级管理员、租户管理员角色
+     * @return mutable Set,支持外部改变元素
+     */
+    protected Set<Long> determineInvisibleRoleIds() {
+        UserRoleContainer currentUser = getCurrentUserRoleContainer();
+        // 超级管理员:不限制
+        if (currentUser.isSuperAdmin()) {
+            return new HashSet<>();
+        }
+        // 租户管理员:列表中不显示超级管理员角色
+        if (currentUser.isTenantAdmin()) {
+            return CollUtil.newHashSet(SysConstant.SUPER_ADMIN_ROLE_ID);
+        }
+        // 普通角色:列表中不显示超级管理员、租户管理员角色
+        Set<Long> ret = sysRoleMapper.selectList(
+                new QueryWrapper<SysRoleEntity>()
+                        .lambda()
+                        // 仅取主键ID
+                        .select(SysRoleEntity::getId)
+                        // 值相符
+                        .eq(SysRoleEntity::getValue, SysConstant.TENANT_ADMIN_ROLE_VALUE)
+        ).stream().map(SysRoleEntity::getId).collect(Collectors.toSet());
+        ret.add(SysConstant.SUPER_ADMIN_ROLE_ID);
+        return ret;
+    }
 
     /*
     ----------------------------------------------------------------
@@ -151,9 +275,10 @@ public class SysRoleService {
      * 实体转 BO
      *
      * @param entity 实体
+     * @param fillMenuIds 是否根据实体ID,查询关联菜单IDs并填充到BO
      * @return BO
      */
-    private SysRoleBO entity2BO(SysRoleEntity entity) {
+    private SysRoleBO entity2BO(SysRoleEntity entity, boolean fillMenuIds) {
         if (entity == null) {
             return null;
         }
@@ -162,7 +287,9 @@ public class SysRoleService {
         BeanUtil.copyProperties(entity, bo);
 
         // 可以在此处为BO填充字段
-        bo.setMenuIds(sysRoleMenuRelationService.listMenuIdsByRoleIds(Collections.singleton(bo.getId())));
+        if (fillMenuIds) {
+            bo.setMenuIds(sysRoleMenuRelationService.listMenuIdsByRoleIds(Collections.singleton(bo.getId())));
+        }
         return bo;
     }
 
@@ -170,13 +297,14 @@ public class SysRoleService {
      * 实体 List 转 BO List
      *
      * @param entityList 实体 List
+     * @param fillMenuIds 是否根据实体ID,查询关联菜单IDs并填充到BO
      * @return BO List
      */
-    private List<SysRoleBO> entityList2BOs(List<SysRoleEntity> entityList) {
+    private List<SysRoleBO> entityList2BOs(List<SysRoleEntity> entityList, boolean fillMenuIds) {
         // 深拷贝
         List<SysRoleBO> ret = new ArrayList<>(entityList.size());
         entityList.forEach(
-                entity -> ret.add(this.entity2BO(entity))
+                entity -> ret.add(this.entity2BO(entity, fillMenuIds))
         );
 
         return ret;
@@ -186,15 +314,16 @@ public class SysRoleService {
      * 实体分页转 BO 分页
      *
      * @param entityPage 实体分页
+     * @param fillMenuIds 是否根据实体ID,查询关联菜单IDs并填充到BO
      * @return BO 分页
      */
-    private PageResult<SysRoleBO> entityPage2BOPage(Page<SysRoleEntity> entityPage) {
+    private PageResult<SysRoleBO> entityPage2BOPage(Page<SysRoleEntity> entityPage, boolean fillMenuIds) {
         return new PageResult<SysRoleBO>()
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                // 需填充菜单IDs
+                .setRecords(this.entityList2BOs(entityPage.getRecords(), fillMenuIds));
     }
 
     /**
@@ -216,27 +345,98 @@ public class SysRoleService {
         if (existingEntity != null && !existingEntity.getId().equals(dto.getId())) {
             throw new BusinessException(400, "已存在相同后台角色,请重新输入");
         }
+
+        if (dto.creatingNewTenantAdmin()) {
+            long qty = sysRoleMapper.selectCount(
+                    new QueryWrapper<SysRoleEntity>()
+                            .lambda()
+                            // 租户ID相同
+                            .eq(SysRoleEntity::getTenantId, dto.getTenantId())
+                            // 角色编码相同
+                            .eq(SysRoleEntity::getValue, dto.getValue())
+                            .last(HelioConstant.CRUD.SQL_LIMIT_1)
+            );
+            SysErrorEnum.NEED_DELETE_EXISTING_TENANT_ADMIN_ROLE.assertTrue(qty <= 0L, dto.getTenantId());
+        }
     }
 
     /**
-     * 取用户ID拥有角色对应的 角色ID-角色名 map
-     *
-     * @param userId 用户ID
-     * @return 失败返回空 map
+     * 新增/编辑后台角色信息前检查
      */
-    public Map<Long, String> getRoleMapByUserId(Long userId) {
-        Set<Long> roleIds = sysUserRoleRelationService.listRoleIdsByUserId(userId);
+    private void preInsertOrUpdateCheck(AdminInsertOrUpdateSysRoleDTO dto) {
+        if (SysConstant.SUPER_ADMIN_ROLE_VALUE.equalsIgnoreCase(dto.getValue())) {
+            // 角色编码不能为SuperAdmin
+            throw new BusinessException(SysErrorEnum.ROLE_VALUE_CANNOT_BE, SysConstant.SUPER_ADMIN_ROLE_VALUE);
+        }
+        if (SysConstant.TENANT_ADMIN_ROLE_VALUE.equalsIgnoreCase(dto.getValue()) && !dto.creatingNewTenantAdmin()) {
+            // 除非是新增租户时,同时新增租户管理员角色,否则角色编码不能为Admin
+            throw new BusinessException(SysErrorEnum.ROLE_VALUE_CANNOT_BE, SysConstant.TENANT_ADMIN_ROLE_VALUE);
+        }
 
-        if (CollUtil.isEmpty(roleIds)) {
-            return Collections.emptyMap();
+        boolean isUpdating = Objects.nonNull(dto.getId());
+        if (isUpdating) {
+            SysRoleEntity existingRole = sysRoleMapper.selectById(dto.getId());
+            SysErrorEnum.INVALID_ID.assertNotNull(existingRole);
+            if (existingRole.isSuperAdmin() || existingRole.isTenantAdmin()) {
+                // 原来角色编码为SuperAdmin或Admin的,不能被改变
+                throw new BusinessException(SysErrorEnum.ROLE_VALUE_CANNOT_BE, existingRole.getValue());
+            }
         }
+    }
 
-        // 根据角色Ids取 map
-        return sysRoleMapper.selectList(
-                new QueryWrapper<SysRoleEntity>()
-                        .lambda()
-                        .select(SysRoleEntity::getId, SysRoleEntity::getValue)
-                        .in(SysRoleEntity::getId, roleIds)
-        ).stream().collect(Collectors.toMap(SysRoleEntity::getId, SysRoleEntity::getValue, StreamFunction.ignoredThrowingMerger()));
+    /**
+     * 删除后台角色前检查
+     */
+    private void preDeleteCheck(Collection<Long> ids) {
+        if (CollUtil.contains(ids, SysConstant.SUPER_ADMIN_ROLE_ID)) {
+            throw new BusinessException(SysErrorEnum.CANNOT_DELETE_SUPER_ADMIN_ROLE);
+        }
+
+        List<SysRoleEntity> existingEntityList = sysRoleMapper.selectBatchIds(ids);
+        for (SysRoleEntity item : existingEntityList) {
+            if (item.isSuperAdmin()) {
+                throw new BusinessException(SysErrorEnum.CANNOT_DELETE_SUPER_ADMIN_ROLE);
+            }
+
+            if (item.isTenantAdmin()) {
+                throw new BusinessException(SysErrorEnum.CANNOT_DELETE_TENANT_ADMIN_ROLE);
+            }
+        }
+
+        UserRoleContainer currentUser = getCurrentUserRoleContainer();
+        if (CollUtil.containsAny(currentUser.getRelatedRoleIds(), ids)) {
+            throw new BusinessException(SysErrorEnum.CANNOT_DELETE_SELF_ROLE);
+        }
+    }
+
+    /**
+     * 绑定后台角色与菜单关联关系前检查
+     * 防止越权访问漏洞
+     */
+    private void preBindRoleMenuRelationCheck(AdminBindRoleMenuRelationDTO dto) {
+        UserRoleContainer currentUser = getCurrentUserRoleContainer();
+        if (SysConstant.SUPER_ADMIN_ROLE_ID.equals(dto.getRoleId())) {
+            throw new BusinessException(SysErrorEnum.CANNOT_BIND_MENUS_FOR_SUPER_ADMIN_ROLE);
+        }
+
+        if (CollUtil.contains(currentUser.getRelatedRoleIds(), dto.getRoleId())) {
+            // 不能动自身角色
+            throw new BusinessException(SysErrorEnum.CANNOT_BIND_MENUS_FOR_SELF);
+        }
+
+        // 有且只有当前用户为超级管理员,才可以为租户管理员绑定菜单
+        SysRoleEntity targetRole = sysRoleMapper.selectById(dto.getRoleId());
+        if (targetRole.isTenantAdmin() && !currentUser.isSuperAdmin()) {
+            throw new BusinessException(SysErrorEnum.CANNOT_BIND_MENUS_FOR_TENANT_ADMIN_ROLE);
+        }
+
+        if (CollUtil.isNotEmpty(dto.getMenuIds()) && !currentUser.isSuperAdmin()) {
+            // 超级管理员之外的角色,都需要校验自身菜单范围是否满足输入值
+            Set<Long> visibleMenuIds = sysRoleMenuRelationService.listMenuIdsByRoleIds(currentUser.getRelatedRoleIds());
+            if (!CollUtil.containsAll(visibleMenuIds, dto.getMenuIds())) {
+                // 可能存在超自身权限赋权
+                throw new BusinessException(SysErrorEnum.BEYOND_AUTHORITY_BIND_MENUS);
+            }
+        }
     }
 }

+ 26 - 14
src/main/java/cc/uncarbon/module/sys/service/SysTenantService.java

@@ -13,6 +13,7 @@ import cc.uncarbon.module.sys.model.request.AdminListSysTenantDTO;
 import cc.uncarbon.module.sys.model.request.AdminUpdateSysTenantDTO;
 import cc.uncarbon.module.sys.model.response.SysTenantBO;
 import cn.hutool.core.bean.BeanUtil;
+import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.text.CharSequenceUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
@@ -24,6 +25,7 @@ import org.springframework.transaction.annotation.Transactional;
 
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.List;
 
 
@@ -58,7 +60,7 @@ public class SysTenantService {
                         .orderByDesc(SysTenantEntity::getCreatedAt)
         );
 
-        return this.entityPage2BOPage(entityPage);
+        return this.entityPage2BOPage(entityPage, true);
     }
 
     /**
@@ -84,7 +86,7 @@ public class SysTenantService {
             SysErrorEnum.INVALID_ID.assertNotNull(entity);
         }
 
-        return this.entity2BO(entity);
+        return this.entity2BO(entity, true);
     }
 
     /**
@@ -108,7 +110,6 @@ public class SysTenantService {
     @Transactional(rollbackFor = Exception.class)
     public void adminUpdate(AdminUpdateSysTenantDTO dto) {
         log.info("[后台管理-编辑系统租户] >> 入参={}", dto);
-        this.checkExistence(dto);
 
         SysTenantEntity entity = new SysTenantEntity();
         BeanUtil.copyProperties(dto, entity);
@@ -151,7 +152,7 @@ public class SysTenantService {
      *
      * @param dto DTO
      */
-    public void checkExistence(AdminUpdateSysTenantDTO dto) {
+    public void checkExistence(AdminInsertSysTenantDTO dto) {
         SysTenantEntity existingEntity = sysTenantMapper.selectOne(
                 new QueryWrapper<SysTenantEntity>()
                         .lambda()
@@ -170,6 +171,18 @@ public class SysTenantService {
         }
     }
 
+    /**
+     * 根据主键IDs,取租户BOs
+     * @param fillTenantAdminUser 是否根据租户管理员用户ID,查询关联用户信息并填充到BO
+     */
+    public List<SysTenantBO> listByIds(Collection<Long> ids, boolean fillTenantAdminUser) {
+        if (CollUtil.isEmpty(ids)) {
+            return Collections.emptyList();
+        }
+        List<SysTenantEntity> entityList = sysTenantMapper.selectBatchIds(ids);
+        return entityList2BOs(entityList, fillTenantAdminUser);
+    }
+
     /*
     ----------------------------------------------------------------
                         私有方法 private methods
@@ -180,9 +193,10 @@ public class SysTenantService {
      * 实体转 BO
      *
      * @param entity 实体
+     * @param fillTenantAdminUser 是否根据租户管理员用户ID,查询关联用户信息并填充到BO
      * @return BO
      */
-    private SysTenantBO entity2BO(SysTenantEntity entity) {
+    private SysTenantBO entity2BO(SysTenantEntity entity, boolean fillTenantAdminUser) {
         if (entity == null) {
             return null;
         }
@@ -191,10 +205,8 @@ public class SysTenantService {
         BeanUtil.copyProperties(entity, bo);
 
         // 可以在此处为BO填充字段
-        if (ObjectUtil.isNotNull(entity.getTenantAdminUserId())) {
-            bo
-                    .setTenantAdminUser(sysUserMapper.getBaseInfoByUserId(entity.getTenantAdminUserId()))
-            ;
+        if (fillTenantAdminUser && ObjectUtil.isNotNull(entity.getTenantAdminUserId())) {
+            bo.setTenantAdminUser(sysUserMapper.getBaseInfoByUserId(entity.getTenantAdminUserId()));
         }
 
         return bo;
@@ -204,13 +216,14 @@ public class SysTenantService {
      * 实体 List 转 BO List
      *
      * @param entityList 实体 List
+     * @param fillTenantAdminUser 是否根据租户管理员用户ID,查询关联用户信息并填充到BO
      * @return BO List
      */
-    private List<SysTenantBO> entityList2BOs(List<SysTenantEntity> entityList) {
+    private List<SysTenantBO> entityList2BOs(List<SysTenantEntity> entityList, boolean fillTenantAdminUser) {
         // 深拷贝
         List<SysTenantBO> ret = new ArrayList<>(entityList.size());
         entityList.forEach(
-                entity -> ret.add(this.entity2BO(entity))
+                entity -> ret.add(this.entity2BO(entity, fillTenantAdminUser))
         );
 
         return ret;
@@ -222,13 +235,12 @@ public class SysTenantService {
      * @param entityPage 实体分页
      * @return BO 分页
      */
-    private PageResult<SysTenantBO> entityPage2BOPage(Page<SysTenantEntity> entityPage) {
+    private PageResult<SysTenantBO> entityPage2BOPage(Page<SysTenantEntity> entityPage, boolean fillTenantAdminUser) {
         return new PageResult<SysTenantBO>()
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                .setRecords(this.entityList2BOs(entityPage.getRecords(), fillTenantAdminUser));
     }
 
 }

+ 22 - 1
src/main/java/cc/uncarbon/module/sys/service/SysUserDeptRelationService.java

@@ -3,6 +3,7 @@ package cc.uncarbon.module.sys.service;
 import cc.uncarbon.framework.core.constant.HelioConstant;
 import cc.uncarbon.module.sys.entity.SysUserDeptRelationEntity;
 import cc.uncarbon.module.sys.mapper.SysUserDeptRelationMapper;
+import cn.hutool.core.collection.CollUtil;
 import cn.hutool.core.util.ObjectUtil;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import lombok.RequiredArgsConstructor;
@@ -10,8 +11,11 @@ import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 
 /**
@@ -28,7 +32,7 @@ public class SysUserDeptRelationService {
     /**
      * 列举用户ID关联的部门IDs
      *
-     * @return 联的部门IDs;目前最多只有1个元素
+     * @return 联的部门IDs;目前最多只有1个元素
      */
     public List<Long> getUserDeptIds(Long userId) {
         SysUserDeptRelationEntity entity = sysUserDeptRelationMapper.selectOne(
@@ -67,4 +71,21 @@ public class SysUserDeptRelationService {
 
     }
 
+    /**
+     * 列举部门IDs关联的用户IDs
+     */
+    public Set<Long> listUserIdsByDeptIds(Collection<Long> deptIds) {
+        if (CollUtil.isEmpty(deptIds)) {
+            return Collections.emptySet();
+        }
+
+        return sysUserDeptRelationMapper.selectList(
+                new QueryWrapper<SysUserDeptRelationEntity>()
+                        .lambda()
+                        // 只要用户ID
+                        .select(SysUserDeptRelationEntity::getUserId)
+                        .in(SysUserDeptRelationEntity::getDeptId, deptIds)
+        ).stream().map(SysUserDeptRelationEntity::getUserId).collect(Collectors.toSet());
+    }
+
 }

+ 20 - 0
src/main/java/cc/uncarbon/module/sys/service/SysUserRoleRelationService.java

@@ -10,6 +10,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import java.util.Collection;
+import java.util.Collections;
 import java.util.Set;
 import java.util.stream.Collectors;
 
@@ -83,4 +84,23 @@ public class SysUserRoleRelationService {
                         .eq(SysUserRoleRelationEntity::getUserId, userId)
         ).stream().map(SysUserRoleRelationEntity::getRoleId).collect(Collectors.toSet());
     }
+
+    /**
+     * 取角色IDs关联的用户IDs
+     *
+     * @param roleIds 角色IDs
+     * @return 空集合or用户IDs
+     */
+    public Set<Long> listUserIdsByRoleIds(Collection<Long> roleIds) {
+        if (CollUtil.isEmpty(roleIds)) {
+            return Collections.emptySet();
+        }
+
+        return sysUserRoleRelationMapper.selectList(
+                new QueryWrapper<SysUserRoleRelationEntity>()
+                        .lambda()
+                        .select(SysUserRoleRelationEntity::getUserId)
+                        .in(SysUserRoleRelationEntity::getRoleId, roleIds)
+        ).stream().map(SysUserRoleRelationEntity::getUserId).collect(Collectors.toSet());
+    }
 }

+ 251 - 35
src/main/java/cc/uncarbon/module/sys/service/SysUserService.java

@@ -8,13 +8,15 @@ import cc.uncarbon.framework.core.exception.BusinessException;
 import cc.uncarbon.framework.core.page.PageParam;
 import cc.uncarbon.framework.core.page.PageResult;
 import cc.uncarbon.framework.core.props.HelioProperties;
+import cc.uncarbon.module.sys.constant.SysConstant;
 import cc.uncarbon.module.sys.entity.SysTenantEntity;
 import cc.uncarbon.module.sys.entity.SysUserEntity;
 import cc.uncarbon.module.sys.enums.SysErrorEnum;
 import cc.uncarbon.module.sys.enums.SysUserStatusEnum;
 import cc.uncarbon.module.sys.mapper.SysUserMapper;
+import cc.uncarbon.module.sys.model.interior.UserDeptContainer;
+import cc.uncarbon.module.sys.model.interior.UserRoleContainer;
 import cc.uncarbon.module.sys.model.request.*;
-import cc.uncarbon.module.sys.model.response.SysDeptBO;
 import cc.uncarbon.module.sys.model.response.SysUserBO;
 import cc.uncarbon.module.sys.model.response.SysUserLoginBO;
 import cc.uncarbon.module.sys.model.response.VbenAdminUserInfoVO;
@@ -29,10 +31,12 @@ import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.lang.Nullable;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.PostConstruct;
+import java.math.BigInteger;
 import java.time.LocalDateTime;
 import java.util.*;
 import java.util.stream.Collectors;
@@ -53,6 +57,7 @@ public class SysUserService {
     private final SysTenantService sysTenantService;
     private final SysUserDeptRelationService sysUserDeptRelationService;
     private final SysUserRoleRelationService sysUserRoleRelationService;
+    private final SysRoleMenuRelationService sysRoleMenuRelationService;
     private final HelioProperties helioProperties;
 
     private boolean isTenantEnabled;
@@ -66,17 +71,41 @@ public class SysUserService {
      * 后台管理-分页列表
      */
     public PageResult<SysUserBO> adminList(PageParam pageParam, AdminListSysUserDTO dto) {
+        // 预处理:根据【手动选择的部门】筛选用户
+        Set<Long> deptUserIds = Collections.emptySet();
+        if (dto.needFilterBySelectedDeptId()) {
+            deptUserIds = sysUserDeptRelationService.listUserIdsByDeptIds(Collections.singleton(dto.getSelectedDeptId()));
+            if (CollUtil.isEmpty(deptUserIds)) {
+                // 【手动选择的部门】没有任何用户ID,直接返回空列表
+                return new PageResult<>(pageParam);
+            }
+        }
+
+        // 预处理:根据【只能看到本部门及下级部门原则】筛选用户
+        Set<Long> visibleUserIds = determineVisibleDeptUserIds();
+        if (Objects.equals(CollUtil.getFirst(visibleUserIds), BigInteger.ZERO.longValue())) {
+            // 其实啥也看不到……
+            return new PageResult<>(pageParam);
+        }
+
+        Set<Long> invisibleUserIds = determineInvisibleUserIds();
         Page<SysUserEntity> entityPage = sysUserMapper.selectPage(
                 new Page<>(pageParam.getPageNum(), pageParam.getPageSize()),
                 new QueryWrapper<SysUserEntity>()
                         .lambda()
                         // 手机号
                         .like(CharSequenceUtil.isNotBlank(dto.getPhoneNo()), SysUserEntity::getPhoneNo, CharSequenceUtil.cleanBlank(dto.getPhoneNo()))
+                        // 根据【手动选择的部门ID】筛选用户
+                        .in(CollUtil.isNotEmpty(deptUserIds), SysUserEntity::getId, deptUserIds)
+                        // 根据【只能看到本部门及下级部门原则】筛选用户
+                        .in(CollUtil.isNotEmpty(visibleUserIds), SysUserEntity::getId, visibleUserIds)
+                        // 不显示特定用户
+                        .notIn(CollUtil.isNotEmpty(invisibleUserIds), SysUserEntity::getId, invisibleUserIds)
                         // 排序
                         .orderByDesc(SysUserEntity::getCreatedAt)
         );
 
-        return this.entityPage2BOPage(entityPage);
+        return this.entityPage2BOPage(entityPage, true);
     }
 
     /**
@@ -97,12 +126,14 @@ public class SysUserService {
      * @return null or BO
      */
     public SysUserBO getOneById(Long id, boolean throwIfInvalidId) throws BusinessException {
+        dataScopeCheck(Collections.singleton(id));
+
         SysUserEntity entity = sysUserMapper.selectById(id);
         if (throwIfInvalidId) {
             SysErrorEnum.INVALID_ID.assertNotNull(entity);
         }
 
-        return this.entity2BO(entity);
+        return this.entity2BO(entity, true);
     }
 
     /**
@@ -115,6 +146,14 @@ public class SysUserService {
         log.info("[后台管理-新增后台用户] >> 入参={}", dto);
         this.checkExistence(dto);
 
+        if (Objects.nonNull(dto.getDeptId())) {
+            // 对传入的部门ID,做数据越权检查
+            UserDeptContainer deptContainer = sysDeptService.getCurrentUserDeptContainer(true);
+            if (deptContainer.hasVisibleDepts() && !CollUtil.contains(deptContainer.getVisibleDeptIds(), dto.getDeptId())) {
+                throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+            }
+        }
+
         dto.setId(null);
         SysUserEntity entity = new SysUserEntity();
         BeanUtil.copyProperties(dto, entity);
@@ -123,8 +162,7 @@ public class SysUserService {
         entity
                 .setSalt(salt)
                 .setPin(dto.getUsername())
-                .setPwd(PwdUtil.encrypt(dto.getPasswordOfNewUser(), salt))
-        ;
+                .setPwd(PwdUtil.encrypt(dto.getPasswordOfNewUser(), salt));
 
         sysUserMapper.insert(entity);
 
@@ -139,16 +177,17 @@ public class SysUserService {
     @Transactional(rollbackFor = Exception.class)
     public void adminUpdate(AdminInsertOrUpdateSysUserDTO dto) {
         log.info("[后台管理-编辑后台用户] >> 入参={}", dto);
+        preUpdateCheck(dto.getId(), dto.getStatus());
         this.checkExistence(dto);
 
-        SysUserEntity updateEntity = new SysUserEntity();
-        BeanUtil.copyProperties(dto, updateEntity);
+        SysUserEntity entity = new SysUserEntity();
+        BeanUtil.copyProperties(dto, entity);
         // 手动处理异名字段
-        updateEntity.setPin(dto.getUsername());
+        entity.setPin(dto.getUsername());
 
         sysUserDeptRelationService.cleanAndBind(dto.getId(), dto.getDeptId());
 
-        sysUserMapper.updateById(updateEntity);
+        sysUserMapper.updateById(entity);
     }
 
     /**
@@ -157,6 +196,7 @@ public class SysUserService {
     @Transactional(rollbackFor = Exception.class)
     public void adminDelete(Collection<Long> ids) {
         log.info("[后台管理-删除后台用户] >> 入参={}", ids);
+        preDeleteCheck(ids);
         sysUserMapper.deleteBatchIds(ids);
     }
 
@@ -166,7 +206,6 @@ public class SysUserService {
     public SysUserLoginBO adminLogin(SysUserLoginDTO dto) {
         /*
         如果启用了多租户功能,并且前端指定了租户ID,则先查库确认租户是否有效
-
         注意:数据源级多租户,登录前【必须】主动指定租户ID,如: dto.setTenantId(101L)
          */
         // ConcurrentHashMap 的 value 不能为 null,还是 new 一个吧
@@ -205,7 +244,7 @@ public class SysUserService {
         this.updateLastLoginAt(sysUserEntity.getId(), LocalDateTimeUtil.now());
 
         // 取账号完整信息
-        SysUserBO sysUserBO = this.entity2BO(sysUserEntity);
+        SysUserBO sysUserBO = this.entity2BO(sysUserEntity, false);
         Map<Long, String> roleMap = sysRoleService.getRoleMapByUserId(sysUserBO.getId());
 
         Map<Long, Set<String>> roleIdPermissionMap = sysMenuService.getRoleIdPermissionMap(roleMap.keySet());
@@ -221,8 +260,7 @@ public class SysUserService {
                 .setRoles(new ArrayList<>(roleMap.values()))
                 .setPermissions(permissions)
                 .setRoleIdPermissionMap(roleIdPermissionMap)
-                .setTenantContext(tenantContext)
-        ;
+                .setTenantContext(tenantContext);
 
         return ret;
     }
@@ -243,13 +281,13 @@ public class SysUserService {
      * 后台管理-重置某用户密码
      */
     public void adminResetUserPassword(AdminResetSysUserPasswordDTO dto) {
+        preUpdateCheck(dto.getUserId(), null);
         SysUserEntity sysUserEntity = sysUserMapper.selectById(dto.getUserId());
 
         SysUserEntity templateEntity = new SysUserEntity();
         templateEntity
                 .setPwd(PwdUtil.encrypt(dto.getRandomPassword(), sysUserEntity.getSalt()))
-                .setId(dto.getUserId())
-        ;
+                .setId(dto.getUserId());
 
         sysUserMapper.updateById(templateEntity);
     }
@@ -265,8 +303,7 @@ public class SysUserService {
 
         sysUserEntity
                 .setPwd(PwdUtil.encrypt(dto.getConfirmNewPassword(), sysUserEntity.getSalt()))
-                .setId(UserContextHolder.getUserId())
-        ;
+                .setId(UserContextHolder.getUserId());
 
         sysUserMapper.updateById(sysUserEntity);
     }
@@ -275,6 +312,7 @@ public class SysUserService {
      * 后台管理-绑定用户与角色关联关系
      */
     public void adminBindRoles(AdminBindUserRoleRelationDTO dto) {
+        preBindUserRoleRelationCheck(dto);
         sysUserRoleRelationService.cleanAndBind(dto.getUserId(), dto.getRoleIds());
     }
 
@@ -294,10 +332,29 @@ public class SysUserService {
         if (ObjectUtil.isNull(userId)) {
             return Collections.emptySet();
         }
-
         return sysRoleService.getRoleMapByUserId(userId).keySet();
     }
 
+    /**
+     * 后台管理 - 取租户用户IDs
+     * @param tenantId 租户ID,非主键ID
+     * @param statusEnums 仅保留符合指定状态的,可以为null
+     */
+    public List<Long> listUserIdsByTenantId(Long tenantId, Collection<EnabledStatusEnum> statusEnums) {
+        if (Objects.isNull(tenantId)) {
+            return Collections.emptyList();
+        }
+        // 备份原始租户上下文;以下查询方式可同时兼容行级、数据源级多租户
+        TenantContext originContext = TenantContextHolder.getTenantContext();
+        try {
+            // 临时切换租户
+            TenantContextHolder.setTenantContext(new TenantContext(tenantId, CharSequenceUtil.EMPTY));
+            return sysUserMapper.selectIds(statusEnums);
+        } finally {
+            TenantContextHolder.setTenantContext(originContext);
+        }
+    }
+
     /*
     ----------------------------------------------------------------
                         私有方法 private methods
@@ -308,9 +365,10 @@ public class SysUserService {
      * 实体转 BO
      *
      * @param entity 实体
+     * @param fillDeptInfo 是否根据实体部门ID,查询关联部门信息并填充到BO
      * @return BO
      */
-    private SysUserBO entity2BO(SysUserEntity entity) {
+    private SysUserBO entity2BO(SysUserEntity entity, boolean fillDeptInfo) {
         if (entity == null) {
             return null;
         }
@@ -320,14 +378,11 @@ public class SysUserService {
 
         // 可以在此处为BO填充字段
         bo.setUsername(entity.getPin());
-        SysDeptBO dept = sysDeptService.getPlainDeptByUserId(bo.getId());
-        if (dept != null) {
-            bo
-                    .setDeptId(dept.getId())
-                    .setDeptTitle(dept.getTitle())
-            ;
+        if (fillDeptInfo) {
+            Optional.ofNullable(sysDeptService.getSpecifiedUserDeptContainer(bo.getId(), false))
+                    .map(UserDeptContainer::primaryRelatedDept)
+                    .ifPresent(deptInfo -> bo.setDeptId(deptInfo.getId()).setDeptTitle(deptInfo.getTitle()));
         }
-
         return bo;
     }
 
@@ -335,9 +390,10 @@ public class SysUserService {
      * 实体 List 转 BO List
      *
      * @param entityList 实体 List
+     * @param fillDeptInfo 是否根据实体部门ID,查询关联部门信息并填充到BO
      * @return BO List
      */
-    private List<SysUserBO> entityList2BOs(List<SysUserEntity> entityList) {
+    private List<SysUserBO> entityList2BOs(List<SysUserEntity> entityList, boolean fillDeptInfo) {
         if (CollUtil.isEmpty(entityList)) {
             return Collections.emptyList();
         }
@@ -345,7 +401,7 @@ public class SysUserService {
         // 深拷贝
         List<SysUserBO> ret = new ArrayList<>(entityList.size());
         entityList.forEach(
-                entity -> ret.add(this.entity2BO(entity))
+                entity -> ret.add(this.entity2BO(entity, fillDeptInfo))
         );
 
         return ret;
@@ -355,15 +411,15 @@ public class SysUserService {
      * 实体分页转 BO 分页
      *
      * @param entityPage 实体分页
+     * @param fillDeptInfo 是否根据实体部门ID,查询关联部门信息并填充到BO
      * @return BO 分页
      */
-    private PageResult<SysUserBO> entityPage2BOPage(Page<SysUserEntity> entityPage) {
+    private PageResult<SysUserBO> entityPage2BOPage(Page<SysUserEntity> entityPage, boolean fillDeptInfo) {
         return new PageResult<SysUserBO>()
                 .setCurrent(entityPage.getCurrent())
                 .setSize(entityPage.getSize())
                 .setTotal(entityPage.getTotal())
-                .setRecords(this.entityList2BOs(entityPage.getRecords()))
-                ;
+                .setRecords(this.entityList2BOs(entityPage.getRecords(), fillDeptInfo));
     }
 
     /**
@@ -379,6 +435,46 @@ public class SysUserService {
         }
     }
 
+    /**
+     * 确定本部门及下级部门用户IDs
+     * 返回空集合代表不限制
+     * 返回[0]或有元素集合,表示有限制
+     */
+    private Set<Long> determineVisibleDeptUserIds() {
+        Set<Long> visibleUserIds = Collections.emptySet();
+        UserDeptContainer deptContainer = sysDeptService.getCurrentUserDeptContainer(true);
+        if (deptContainer.hasVisibleDepts()) {
+            visibleUserIds = sysUserDeptRelationService.listUserIdsByDeptIds(deptContainer.getVisibleDeptIds());
+            if (CollUtil.isEmpty(visibleUserIds)) {
+                // 【可见部门】没有任何用户ID,直接返回[0]
+                return Collections.singleton(BigInteger.ZERO.longValue());
+            }
+        }
+        return visibleUserIds;
+    }
+
+    /**
+     * 确定不可见用户IDs
+     * 租户管理员:列表中不显示超级管理员用户
+     * 普通用户:列表中不显示超级管理员、租户管理员用户
+     */
+    private Set<Long> determineInvisibleUserIds() {
+        Set<Long> invisibleRoleIds = sysRoleService.determineInvisibleRoleIds();
+        return sysUserRoleRelationService.listUserIdsByRoleIds(invisibleRoleIds);
+    }
+
+    /**
+     * 数据越权检查
+     */
+    private void dataScopeCheck(Collection<Long> userIds) {
+        Set<Long> visibleUserIds = determineVisibleDeptUserIds();
+        Set<Long> invisibleUserIds = determineInvisibleUserIds();
+        if (CollUtil.isNotEmpty(visibleUserIds) && !CollUtil.containsAll(visibleUserIds, userIds)
+                || CollUtil.isNotEmpty(invisibleUserIds) && CollUtil.containsAny(invisibleUserIds, userIds)) {
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+        }
+    }
+
     /**
      * 检查并获取租户上下文 bean,无效或被禁用则直接抛出异常
      * @param tenantId 租户ID
@@ -398,16 +494,136 @@ public class SysUserService {
         return TenantContext.builder()
                 .tenantId(tenantEntity.getTenantId())
                 .tenantName(tenantEntity.getTenantName())
-                .build()
-        ;
+                .build();
     }
 
     private void updateLastLoginAt(Long userId, LocalDateTime lastLoginAt) {
         SysUserEntity entity = new SysUserEntity();
         entity
                 .setLastLoginAt(lastLoginAt)
-                .setId(userId)
-                ;
+                .setId(userId);
         sysUserMapper.updateById(entity);
     }
+
+    /**
+     * 编辑后台用户信息前检查
+     * @param specifiedUserId 被操作用户ID
+     * @param statusEnum 用户状态枚举,可以为null
+     */
+    private void preUpdateCheck(Long specifiedUserId, @Nullable SysUserStatusEnum statusEnum) {
+        UserRoleContainer currentUser = sysRoleService.getCurrentUserRoleContainer();
+        if (currentUser.isSuperAdmin()) {
+            // 超级管理员除禁用自己外为所欲为
+            if (statusEnum == SysUserStatusEnum.BANNED && Objects.equals(specifiedUserId, UserContextHolder.getUserId())) {
+                throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_SELF_USER);
+            }
+            return;
+        }
+
+        if (Objects.equals(specifiedUserId, UserContextHolder.getUserId())) {
+            // 不能动自身用户
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_SELF_USER);
+        }
+
+        // 目标是超级管理员or租户管理员时,均不能编辑
+        UserRoleContainer specifiedUser = sysRoleService.getSpecifiedUserRoleContainer(specifiedUserId);
+        if (specifiedUser.isSuperAdmin() || specifiedUser.isTenantAdmin()) {
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+        }
+
+        dataScopeCheck(Collections.singleton(specifiedUserId));
+        // 暂未实现角色层级,一律平级
+    }
+
+    /**
+     * 删除后台用户前检查
+     */
+    private void preDeleteCheck(Collection<Long> ids) {
+        if (CollUtil.contains(ids, UserContextHolder.getUserId())) {
+            // 不能动自身用户
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_SELF_USER);
+        }
+
+        // 目标是超级管理员时,不能删除
+        List<UserRoleContainer> specifiedUsers = ids.stream().map(sysRoleService::getSpecifiedUserRoleContainer).collect(Collectors.toList());
+        if (specifiedUsers.stream().anyMatch(UserRoleContainer::isSuperAdmin)) {
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+        }
+
+        // 只有超级管理员可以删租户管理员用户
+        UserRoleContainer currentUser = sysRoleService.getCurrentUserRoleContainer();
+        if (specifiedUsers.stream().anyMatch(UserRoleContainer::isTenantAdmin) && !currentUser.isSuperAdmin()) {
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+        }
+
+        dataScopeCheck(ids);
+        // 暂未实现角色层级,一律平级
+    }
+
+    /**
+     * 绑定后台用户与角色关联关系前检查
+     * 防止越权访问漏洞
+     */
+    private void preBindUserRoleRelationCheck(AdminBindUserRoleRelationDTO dto) {
+        UserRoleContainer currentUser = sysRoleService.getCurrentUserRoleContainer();
+        // 是否对自己操作
+        boolean selfFlag = Objects.equals(dto.getUserId(), UserContextHolder.getUserId());
+        if (currentUser.isSuperAdmin()) {
+            // 超级管理员不能去掉自己的超级管理员角色
+            if (selfFlag && !CollUtil.contains(dto.getRoleIds(), SysConstant.SUPER_ADMIN_ROLE_ID)) {
+                throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_SELF_USER);
+            }
+            // 也不能赋予其他人超级管理员角色
+            if (!selfFlag && CollUtil.contains(dto.getRoleIds(), SysConstant.SUPER_ADMIN_ROLE_ID)) {
+                throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+            }
+            return;
+        }
+
+        if (selfFlag) {
+            // 不能动自身用户
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_SELF_USER);
+        }
+
+        // 目标已经是超级管理员or租户管理员时,均不能绑定
+        UserRoleContainer specifiedUser = sysRoleService.getSpecifiedUserRoleContainer(dto.getUserId());
+        if (specifiedUser.isSuperAdmin() || specifiedUser.isTenantAdmin()) {
+            throw new BusinessException(SysErrorEnum.CANNOT_OPERATE_THIS_USER);
+        }
+
+        // 超级管理员之外的用户,都需要校验自身角色范围是否满足输入值
+        currentUserNotSuperAdmin(dto, currentUser);
+
+        dataScopeCheck(Collections.singleton(dto.getUserId()));
+    }
+
+    /**
+     * 绑定后台用户与角色关联关系前检查
+     * 超级管理员之外的用户,都需要校验自身角色范围是否满足输入值
+     * 拆分子方法以降低Cognitive Complexity
+     */
+    private void currentUserNotSuperAdmin(AdminBindUserRoleRelationDTO dto, UserRoleContainer currentUser) {
+        if (CollUtil.isNotEmpty(dto.getRoleIds()) && !currentUser.isSuperAdmin()) {
+            boolean overRoles = !CollUtil.containsAll(currentUser.getRelatedRoleIds(), dto.getRoleIds());
+            if (overRoles && currentUser.isNotAnyAdmin()) {
+                // 普通用户超自身角色授予了;如果当前用户拥有新角色的所有菜单,那么也放行
+                Set<Long> grantedMenuIds = sysRoleMenuRelationService.listMenuIdsByRoleIds(currentUser.getRelatedRoleIds());
+                Set<Long> needMenuIds = sysRoleMenuRelationService.listMenuIdsByRoleIds(dto.getRoleIds());
+                if (!CollUtil.containsAll(grantedMenuIds, needMenuIds)) {
+                    throw new BusinessException(SysErrorEnum.BEYOND_AUTHORITY_BIND_ROLES);
+                }
+            }
+
+            if (currentUser.isTenantAdmin()) {
+                // 超自身权限,但作为租户管理员有额外情况
+                Set<Long> invisibleRoleIds = sysRoleService.determineInvisibleRoleIds();
+                // 除非超越了可见角色IDs授予 or 想要授予用户租户管理员角色,否则不管
+                invisibleRoleIds.addAll(currentUser.getRelatedRoleIds());
+                if (CollUtil.containsAny(invisibleRoleIds, dto.getRoleIds())) {
+                    throw new BusinessException(SysErrorEnum.BEYOND_AUTHORITY_BIND_ROLES);
+                }
+            }
+        }
+    }
+
 }

+ 1 - 1
src/main/java/cc/uncarbon/module/sys/util/PwdUtil.java

@@ -11,7 +11,7 @@ import lombok.experimental.UtilityClass;
 @UtilityClass
 public class PwdUtil {
     public static String encrypt(String str, String salt) {
-        if (CharSequenceUtil.isEmpty(str)){
+        if (CharSequenceUtil.isEmpty(str)) {
             return "";
         }
 

+ 2 - 1
src/main/resources/application.yml

@@ -86,7 +86,8 @@ mybatis-plus:
 logging:
   level:
     # 指定日志级别,开发、测试环境建议为 DEBUG,生产环境建议为 INFO
-    cc.uncarbon: DEBUG
+    cc.uncarbon.framework: DEBUG
+    cc.uncarbon.module: DEBUG
 
 # 详细配置文档:https://sa-token.cc/doc.html#/use/config
 sa-token:

+ 1 - 1
src/main/resources/banner.txt

@@ -10,4 +10,4 @@ ${AnsiColor.BLUE}
 ${AnsiColor.GREEN}
 Spring Boot Version: ${spring-boot.version}
 
-${AnsiColor.BLACK}
+${AnsiColor.WHITE}

+ 0 - 0
多模块版请切换至multi分支.md → 多模块版请移步至helio-boot-modular项目.md