Compare commits

...

14 Commits

Author SHA1 Message Date
e77353b478 feat(minio): 集成 MinIO 工具类并优化对象存储客户端构建
- 新增 MinioUtil 工具类,统一封装 MinIO SDK 的常用操作
- 规范化 endpoint 处理,避免签名校验时因路径错误失败
- 统一使用 MinioUtil.buildClient 创建 MinIO 客户端,避免自动探测 region 导致错误
- 实现自动创建 bucket 及设置匿名只读策略,兼容裸桶场景
- 进程内缓存已准备好的 bucket,减少重复请求
- 修正 AliOssController 和 FileRecordServiceImpl 的 MinIO 客户端初始化逻辑
2026-08-07 09:23:18 +08:00
e487f021dc fix(storage): 规范化endpoint并指定MinIO客户端region
- 去除endpoint结尾斜杠,避免minio-java拼接双斜杠导致签名不匹配
- MinIO客户端构建时显式指定region为us-east-1
- 跳过MinIO SDK的自动getBucketLocation探测,避免反向代理环境下签名验证失败
- 修改AliOssController和FileRecordServiceImpl中相关代码逻辑
2026-08-07 04:00:39 +08:00
5ca3280ddc feat(storage): 支持 MinIO 存储并优化上传与删除逻辑
- 引入 MinIO 官方 Java SDK,替代 aliyun OSS SDK 处理与 MinIO 的交互
- 兼容 MinIO 的 AWS4-HMAC-SHA256 签名及 path-style 访问
- 区分上传和删除操作中 MinIO 与阿里云 OSS 的客户端实例创建及调用
- 统一文件路径处理,调整图片处理策略以兼容 MinIO 不支持的参数
- 增加对 MinIO 客户端异常的捕获与兼容处理
- 在 pom.xml 中添加对 okhttp 和 okio 依赖的版本锁定,解决版本冲突问题
2026-08-07 03:46:49 +08:00
b9ec1fcdb9 fix(system): 修正站点域名配置及默认应用代码
- 修改 site 应用对应的域名后缀为 sitelnk.cn
- 调整默认应用代码由 mp 改为 site
- 更新域名映射中 site 项的配置为新的域名地址
- 保障邮件通知链接生成正确的站点域名
2026-08-07 03:17:28 +08:00
a6a2e14abf fix(storage): 关闭 MinIO CNAME 支持解决证书不匹配问题
- 在 AliOssController 中关闭 MinIO 的 CNAME 支持,避免自定义域名被当作 CNAME
- 强制使用 path-style 访问方式,解决虚拟主机风格导致的证书不匹配
- 在 FileRecordServiceImpl 中同步应用同样的配置修改
- 修复了因 OSSV4Signer.getRegion() 返回 null 导致的 NPE 问题
2026-08-07 03:03:25 +08:00
18696f91a6 fix(oss): 修复MinIO SDK配置避免region空指针异常
- 调整MinIO客户端配置,使用 OSSClientBuilder 的链式调用构建
- 明确指定region为"us-east-1",防止自定义域名推导region失败导致NPE
- 保持SignatureVersion为V4,启用从endpoint提取配置信息以支持path-style访问
- 统一AliOssController和FileRecordServiceImpl中的MinIO配置逻辑
2026-08-07 02:54:21 +08:00
49e6cd2197 feat(system): 支持按应用代号发送开通成功邮件
- 在 Company 和 User 实体中新增 appCode 字段,接收前端传入的应用代号
- MainController 传递 appCode 到 Company 实体,用于后续处理
- TenantServiceImpl 根据 appCode 动态生成不同域名的网址和后台管理地址
- 优化企业开通成功邮件内容,增强安全提示和登录信息展示
- 邮件发送逻辑改为根据应用代号映射对应的站点和后台域名
2026-08-07 02:36:10 +08:00
f7f7b30813 feat(auth): 添加邮箱验证码校验接口
- 在EmailCaptchaParam中新增验证码字段code,用于验证码校验
- 新增/verifyEmailCaptcha接口,支持邮箱验证码的校验逻辑
- 实现邮箱格式和验证码非空校验,提供错误反馈
- 验证通过后删除Redis中对应验证码,防止重复使用
- 支持开发环境验证码验证,方便调试验证流程
2026-08-07 01:48:12 +08:00
b043270e52 feat(auth): 添加邮箱验证码校验接口
- 在EmailCaptchaParam中新增验证码字段code,用于验证码校验
- 新增/verifyEmailCaptcha接口,支持邮箱验证码的校验逻辑
- 实现邮箱格式和验证码非空校验,提供错误反馈
- 验证通过后删除Redis中对应验证码,防止重复使用
- 支持开发环境验证码验证,方便调试验证流程
2026-08-07 01:42:54 +08:00
f720adc2a8 feat(system): 实现登录用户所属租户切换功能
- 增加判断当前登录用户切换到自身租户的直接放行逻辑
- 在租户切换成功时生成新的访问令牌并返回
- 异步保存登录记录,记录切换行为
- 解决注册即时场景下新超管租户切换验证不足的问题
2026-08-06 23:05:16 +08:00
2e51710e3a feat(system): 实现跨租户克隆授权功能
- 新增RoleMenuMapper和UserRoleMapper跨租户查询方法,支持忽略租户拦截器
- 实现grantCmsPermission方法,支持从参考租户克隆角色、菜单、角色菜单和用户角色到当前租户
- 角色克隆按role_code去重,已存在角色复用,新增角色批量保存
- 菜单克隆按层级复制,避免重复插入,保证父子菜单顺序
- 角色菜单克隆使用映射关系去重,批量保存新增绑定
- 用户角色克隆根据角色代码对齐用户名,避免重复绑定
- 返回克隆后的各资源统计数据,包括新增角色、菜单、角色菜单和用户角色数量
2026-08-06 21:21:10 +08:00
644188288a feat(api): 新增邮箱注册检查接口
- 新增 /checkEmailRegistered GET 接口,用于检查邮箱是否已注册
- 实现邮箱格式校验,保证输入的邮箱地址有效
- 查询邮箱注册的账户列表,统计账号数量并返回是否已注册
- 返回结果封装在 CheckEmailResult 类中,包含注册状态及账号数量
- 在 SecurityConfig 中添加 /api/checkEmailRegistered 接口白名单配置
2026-08-06 19:25:36 +08:00
d532f6717f fix(security): 移除登录白名单中的 /api/grantCmsPermission 接口
- 从安全配置中剔除 /api/grantCmsPermission 接口的免认证访问
- 加强接口访问控制,防止未授权的权限授予操作
- 确保 CMS 权限管理接口需要严格认证验证
2026-08-06 19:12:11 +08:00
4612155457 fix(security): 添加/api/grantCmsPermission接口到安全忽略列表
- 在SecurityConfig中将/api/grantCmsPermission加入忽略验证的接口列表
- 确保该接口无需身份验证即可访问
- 维护现有的公共接口安全策略
2026-08-06 16:52:37 +08:00
15 changed files with 541 additions and 105 deletions

32
pom.xml
View File

@@ -26,7 +26,31 @@
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
</properties> </properties>
<dependencyManagement>
<dependencies> <dependencies>
<!-- 强制 okhttp/okio 版本,避免 minio-java(要求 okhttp>=4.11.0) 与微信/支付 SDK 引入的 okhttp3 旧版冲突 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.squareup.okio</groupId>
<artifactId>okio</artifactId>
<version>3.6.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- 强制 okhttp 4.12.0minio-java 要求 okhttp>=4.11.0,放在 dependencies 首位确保胜出,
避免运行时 NoSuchMethodErrorokhttp3.RequestBody.create 等) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- spring-boot-devtools --> <!-- spring-boot-devtools -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
@@ -297,6 +321,14 @@
<version>3.17.4</version> <version>3.17.4</version>
</dependency> </dependency>
<!-- MinIO 官方 Java SDK用于对接 MinIO 兼容 S3 存储。
注意 aliyun OSS SDK 无 AWS4-HMAC-SHA256 签名器,无法对接 MinIO故 minio 分支单独用此 SDK。 -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
<!-- 阿里云 内容安全审核 --> <!-- 阿里云 内容安全审核 -->
<dependency> <dependency>
<groupId>com.aliyun</groupId> <groupId>com.aliyun</groupId>

View File

@@ -47,6 +47,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/findAccountByPhone", "/api/findAccountByPhone",
"/api/resetPassword", "/api/resetPassword",
"/api/checkPhoneRegistered", "/api/checkPhoneRegistered",
"/api/checkEmailRegistered",
"/api/existence", "/api/existence",
"/api/oss/upload", "/api/oss/upload",
"/druid/**", "/druid/**",
@@ -56,6 +57,7 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
"/api/sendSmsCaptcha", "/api/sendSmsCaptcha",
"/api/sendSmsCaptchaByAdmin", "/api/sendSmsCaptchaByAdmin",
"/api/sendEmailCaptcha", "/api/sendEmailCaptcha",
"/api/verifyEmailCaptcha",
"/api/loginBySms", "/api/loginBySms",
"/api/loginBySuperAdminSms", "/api/loginBySuperAdminSms",
"/api/loginBySelectTenant", "/api/loginBySelectTenant",

View File

@@ -0,0 +1,151 @@
package com.gxwebsoft.common.core.utils;
import io.minio.BucketExistsArgs;
import io.minio.GetBucketPolicyArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.SetBucketPolicyArgs;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* MinIO 对象存储工具类
*
* <p>说明aliyun-sdk-oss 只实现了阿里云自家的 OSS 签名OSSV1/V2/V4Signer
* 不含 AWS4-HMAC-SHA256因此无法对接只接受 AWS4 的 MinIO。
* 所有 MinIO 操作统一走官方 minio-java SDK。
*
* @author WebSoft
* @since 2026-08-07
*/
public class MinioUtil {
/**
* MinIO 默认 region。
* 必须显式指定,否则 minio-java 会先发 {@code GET /{bucket}?location=} 做 region 自动探测,
* 该探测请求在 MinIO + 反向代理场景下容易触发 SignatureDoesNotMatch。
*/
public static final String DEFAULT_REGION = "us-east-1";
/**
* 已确保「存在 + 已配置匿名只读策略」的 bucket 缓存endpoint|bucket
* 进程内缓存,避免每次上传都多发两次 HTTP 请求。
*/
private static final Set<String> READY_BUCKETS = ConcurrentHashMap.newKeySet();
private MinioUtil() {
}
/**
* 规范化 endpoint去掉结尾斜杠避免 minio-java 拼接出双斜杠导致签名不匹配
*/
public static String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
String result = endpoint.trim();
while (result.endsWith("/")) {
result = result.substring(0, result.length() - 1);
}
return result;
}
/**
* 构建 MinioClient统一带上 region跳过 getBucketLocation 探测)
*
* @param endpoint S3 API 地址,如 https://minio.websoft.top
* @param accessKeyId AK
* @param accessKeySecret SK
*/
public static MinioClient buildClient(String endpoint, String accessKeyId, String accessKeySecret) {
return MinioClient.builder()
.endpoint(normalizeEndpoint(endpoint))
.credentials(accessKeyId, accessKeySecret)
.region(DEFAULT_REGION)
.build();
}
/**
* 生成 bucket 的匿名只读策略。
* 只放开读取对象,<b>故意不给 s3:ListBucket</b>,避免匿名用户列出桶内全部文件清单。
*/
public static String buildPublicReadPolicy(String bucketName) {
return "{"
+ "\"Version\":\"2012-10-17\","
+ "\"Statement\":["
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:GetBucketLocation\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "\"]},"
+ "{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"*\"]},"
+ "\"Action\":[\"s3:GetObject\"],"
+ "\"Resource\":[\"arn:aws:s3:::" + bucketName + "/*\"]}"
+ "]}";
}
/**
* 确保 bucket 可用:
* <ol>
* <li>bucket 不存在 → 自动创建,并套上匿名只读策略</li>
* <li>bucket 已存在但没有任何策略 → 补上匿名只读策略(兼容历史手动建的裸桶)</li>
* <li>bucket 已有自定义策略 → 不覆盖,尊重人工配置</li>
* </ol>
* 结果会缓存在进程内,同一个 endpoint+bucket 只检查一次。
*
* <p>本方法不会抛异常:即使当前 AK 没有建桶/改策略权限,也只打日志,
* 后续的上传动作照常执行(真正失败会由上传自身报错)。
*
* @return true 表示 bucket 已就绪
*/
public static boolean ensureBucketReady(MinioClient client, String endpoint, String bucketName) {
if (client == null || bucketName == null || bucketName.isEmpty()) {
return false;
}
String cacheKey = normalizeEndpoint(endpoint) + "|" + bucketName;
if (READY_BUCKETS.contains(cacheKey)) {
return true;
}
try {
boolean exists = client.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
if (!exists) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
client.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName)
.config(buildPublicReadPolicy(bucketName))
.build());
System.out.println("[MinIO] 自动创建 bucket 并配置匿名只读策略: " + bucketName);
} else {
// minio-java 在无策略时返回空串而非抛错
String policy = client.getBucketPolicy(GetBucketPolicyArgs.builder().bucket(bucketName).build());
if (policy == null || policy.trim().isEmpty()) {
client.setBucketPolicy(SetBucketPolicyArgs.builder()
.bucket(bucketName)
.config(buildPublicReadPolicy(bucketName))
.build());
System.out.println("[MinIO] 已为存量 bucket 补充匿名只读策略: " + bucketName);
}
}
READY_BUCKETS.add(cacheKey);
return true;
} catch (Exception e) {
// 不阻断上传:权限不足或网络异常时仅告警
System.out.println("[MinIO] ensureBucketReady 失败(不影响上传), bucket=" + bucketName
+ ", reason=" + e.getMessage());
return false;
}
}
/**
* 手动失效缓存(例如后台修改了存储配置后调用)
*/
public static void evictBucketCache(String endpoint, String bucketName) {
READY_BUCKETS.remove(normalizeEndpoint(endpoint) + "|" + bucketName);
}
/**
* 清空全部缓存
*/
public static void clearBucketCache() {
READY_BUCKETS.clear();
}
}

View File

@@ -24,6 +24,7 @@ import com.aliyuncs.profile.IClientProfile;
import com.gxwebsoft.common.core.annotation.OperationLog; import com.gxwebsoft.common.core.annotation.OperationLog;
import com.gxwebsoft.common.core.config.ConfigProperties; import com.gxwebsoft.common.core.config.ConfigProperties;
import com.gxwebsoft.common.core.utils.FileServerUtil; import com.gxwebsoft.common.core.utils.FileServerUtil;
import com.gxwebsoft.common.core.utils.MinioUtil;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
import com.gxwebsoft.common.core.web.BaseController; import com.gxwebsoft.common.core.web.BaseController;
@@ -37,6 +38,8 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import io.minio.MinioClient;
import io.minio.UploadObjectArgs;
import javax.annotation.Resource; import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -100,6 +103,9 @@ public class AliOssController extends BaseController {
String accessKeySecret = settingInfo.getString("accessKeySecret"); String accessKeySecret = settingInfo.getString("accessKeySecret");
String uploadMethod = settingInfo.getString("uploadMethod"); String uploadMethod = settingInfo.getString("uploadMethod");
// 规范化 endpoint去掉结尾斜杠避免 minio-java 拼接出双斜杠导致签名不匹配
endpoint = MinioUtil.normalizeEndpoint(endpoint);
// 判断是否登录 // 判断是否登录
String authorization = getAuthorization(); String authorization = getAuthorization();
@@ -115,18 +121,19 @@ public class AliOssController extends BaseController {
// 上传文件结果 // 上传文件结果
FileRecord result; FileRecord result;
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret); boolean isMinio = "minio".equals(uploadMethod);
// 创建OSSClient实例。 // MinIO 用官方 minio-java SDK原生 AWS4-HMAC-SHA256 + path-style
// MinIO 兼容 S3 协议,需开启 PathStyle 访问endpoint/bucket/key并使用 V4 签名 // aliyun OSS SDK 无 AWS4 签名器,对接不了 MinIO因此 minio 分支独立使用 MinioClient。
OSS ossClient; OSS ossClient = null;
if ("minio".equals(uploadMethod)) { MinioClient minioClient = null;
ClientBuilderConfiguration minioConfig = new ClientBuilderConfiguration(); if (isMinio) {
// 3.17 已移除 setPathStyleAccess用 setExtractSettingFromEndpoint 让 SDK 对 MinIO 这类 // 显式指定 region=us-east-1跳过 minio-java 的 getBucketLocation 自动探测
// 非阿里云 endpoint 自动按 path-style 访问,并从 endpoint 推导 region // (该探测 GET 在 MinIO + 反代下会触发 SignatureDoesNotMatch
minioConfig.setSignatureVersion(SignVersion.V4); minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
minioConfig.setExtractSettingFromEndpoint(true); // bucket 不存在则自动创建,并套上匿名只读策略(存量裸桶也会自动补策略)
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider, minioConfig); MinioUtil.ensureBucketReady(minioClient, endpoint, bucketName);
} else { } else {
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider); ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
} }
@@ -137,16 +144,18 @@ public class AliOssController extends BaseController {
String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length()); String path = upload.getAbsolutePath().replace("\\", "/").substring(dir.length());
String originalName = file.getOriginalFilename(); String originalName = file.getOriginalFilename();
// 创建PutObjectRequest对象 // 上传文件到对象存储
if (isMinio) {
// minio-javapath 此时无前导 "/",作为对象名(如 2026/08/07/xxx.png默认 region=us-east-1
minioClient.uploadObject(UploadObjectArgs.builder()
.bucket(bucketName)
.object(path)
.filename(upload.getAbsolutePath())
.build());
} else {
PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload); PutObjectRequest putObjectRequest = new PutObjectRequest(bucketName, path, upload);
// 如果需要上传时设置存储类型和访问权限,请参考以下示例代码。 ossClient.putObject(putObjectRequest);
// ObjectMetadata metadata = new ObjectMetadata(); }
// metadata.setHeader(OSSHeaders.OSS_STORAGE_CLASS, StorageClass.Standard.toString());
// metadata.setObjectAcl(CannedAccessControlList.Private);
// putObjectRequest.setMetadata(metadata);
// 上传文件。
PutObjectResult ossResult = ossClient.putObject(putObjectRequest);
// 保存记录并返回 // 保存记录并返回
result = new FileRecord(); result = new FileRecord();
@@ -177,7 +186,7 @@ public class AliOssController extends BaseController {
// 根据文件类型设置缩略图和预览图 // 根据文件类型设置缩略图和预览图
// MinIO 不支持阿里云 x-oss-process 图片处理参数,统一返回原图 URL // MinIO 不支持阿里云 x-oss-process 图片处理参数,统一返回原图 URL
boolean isMinio = "minio".equals(uploadMethod); isMinio = "minio".equals(uploadMethod);
if (FileServerUtil.isImage(contentType)) { if (FileServerUtil.isImage(contentType)) {
if (isMinio) { if (isMinio) {
result.setThumbnail(bucketDomain + path); result.setThumbnail(bucketDomain + path);
@@ -228,10 +237,15 @@ public class AliOssController extends BaseController {
+ "a serious internal problem while trying to communicate with OSS, " + "a serious internal problem while trying to communicate with OSS, "
+ "such as not being able to access the network."); + "such as not being able to access the network.");
System.out.println("Error Message:" + ce.getMessage()); System.out.println("Error Message:" + ce.getMessage());
} catch (Exception e) {
// 兼容 minio-java 抛出的异常MinioException / IOException 等)
System.out.println("Caught an upload Exception: " + e.getMessage());
e.printStackTrace();
} finally { } finally {
if (ossClient != null) { if (ossClient != null) {
ossClient.shutdown(); ossClient.shutdown();
} }
// MinioClient 内部复用 HttpClient 连接池,无需显式关闭
} }
return fail("上传失败", null); return fail("上传失败", null);
} }

View File

@@ -44,6 +44,7 @@ import com.gxwebsoft.common.system.result.CaptchaResult;
import com.gxwebsoft.common.system.result.LoginResult; import com.gxwebsoft.common.system.result.LoginResult;
import com.gxwebsoft.common.system.result.TenantOption; import com.gxwebsoft.common.system.result.TenantOption;
import com.gxwebsoft.common.system.result.AccountInfoResult; import com.gxwebsoft.common.system.result.AccountInfoResult;
import com.gxwebsoft.common.system.result.CheckEmailResult;
import com.gxwebsoft.common.system.result.CheckPhoneResult; import com.gxwebsoft.common.system.result.CheckPhoneResult;
import com.gxwebsoft.common.system.service.*; import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.common.system.mapper.UserMapper; import com.gxwebsoft.common.system.mapper.UserMapper;
@@ -876,6 +877,31 @@ public class MainController extends BaseController {
} }
} }
@Operation(summary = "校验邮箱验证码")
@PostMapping("/verifyEmailCaptcha")
public ApiResult<?> verifyEmailCaptcha(@RequestBody EmailCaptchaParam param) {
if (param == null || StrUtil.isBlank(param.getEmail())) {
return fail("邮箱不能为空");
}
if (!CommonUtil.isValidEmail(param.getEmail())) {
return fail("请输入有效的邮箱地址");
}
if (StrUtil.isBlank(param.getCode())) {
return fail("验证码不能为空");
}
// 与 sendEmailCaptcha 写入的 key 保持一致emailCode:邮箱
String key = RedisConstants.EMAIL_CODE_KEY + ":" + param.getEmail();
String cachedCode = redisUtil.get(key);
String devCode = redisUtil.get(CACHE_KEY_VERIFICATION_CODE_BY_DEV_SMS);
if (cachedCode == null
|| (!param.getCode().equals(cachedCode) && !param.getCode().equals(devCode))) {
return fail("邮箱验证码不正确");
}
// 校验通过即删除,防止重复使用
redisUtil.delete(key);
return success("邮箱验证通过");
}
@OperationLog @OperationLog
@Operation(summary = "重置密码") @Operation(summary = "重置密码")
@PutMapping("/password") @PutMapping("/password")
@@ -1183,6 +1209,13 @@ public class MainController extends BaseController {
if (current == null) { if (current == null) {
return fail("未登录或登录已过期", null); return fail("未登录或登录已过期", null);
} }
// 切换到当前登录用户所属租户(如注册成功后刷新权限令牌)直接放行:
// 注册即时场景下可切换租户列表校验可能未能命中刚创建的新超管,自己切自己一定合法。
if (targetTenantId.equals(current.getTenantId())) {
String access_token = generateToken(current);
loginRecordService.saveAsync(current.getUsername(), LoginRecord.TYPE_LOGIN, null, current.getTenantId(), request);
return success("切换成功", new LoginResult(access_token, current));
}
// 校验目标租户在可切换范围内(与 switchableTenants 一致) // 校验目标租户在可切换范围内(与 switchableTenants 一致)
User target = findSwitchableUser(current, targetTenantId); User target = findSwitchableUser(current, targetTenantId);
if (target == null) { if (target == null) {
@@ -1570,6 +1603,7 @@ public class MainController extends BaseController {
company.setEmail(email); company.setEmail(email);
company.setPhone(phone); company.setPhone(phone);
company.setPassword(password); company.setPassword(password);
company.setAppCode(user.getAppCode());
company.setTid(tenant.getTenantId()); company.setTid(tenant.getTenantId());
company.setShortName(tenantName); company.setShortName(tenantName);
company.setCategoryId(661); company.setCategoryId(661);
@@ -1788,6 +1822,20 @@ public class MainController extends BaseController {
* 检查手机号是否已注册(可选接口) * 检查手机号是否已注册(可选接口)
*/ */
@Operation(summary = "检查手机号是否已注册") @Operation(summary = "检查手机号是否已注册")
@GetMapping("/checkEmailRegistered")
public ApiResult<?> checkEmailRegistered(@RequestParam("email") String email) {
// 邮箱格式校验
if (!CommonUtil.isValidEmail(email)) {
return fail("请输入有效的邮箱地址");
}
// 统计该邮箱注册的账号数量(跨租户,复用 findAccountsByEmail
List<User> accounts = userService.findAccountsByEmail(email);
CheckEmailResult result = new CheckEmailResult();
result.setIsRegistered(accounts != null && !accounts.isEmpty());
result.setAccountCount(accounts != null ? accounts.size() : 0);
return success(result);
}
@GetMapping("/checkPhoneRegistered") @GetMapping("/checkPhoneRegistered")
public ApiResult<?> checkPhoneRegistered(@RequestParam("phone") String phone) { public ApiResult<?> checkPhoneRegistered(@RequestParam("phone") String phone) {
// 验证手机号 // 验证手机号

View File

@@ -294,6 +294,10 @@ public class Company implements Serializable {
@TableField(exist = false) @TableField(exist = false)
private String password; private String password;
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site")
@TableField(exist = false)
private String appCode;
@Schema(description = "手机号(脱敏)") @Schema(description = "手机号(脱敏)")
@TableField(exist = false) @TableField(exist = false)
private String mobile; private String mobile;

View File

@@ -61,6 +61,10 @@ public class User implements UserDetails {
@Schema(description = "邮箱") @Schema(description = "邮箱")
private String email; private String email;
@Schema(description = "应用代号(非数据库字段,注册时由前端传入,用于生成开通邮件中的域名,如 mp/shop/site")
@TableField(exist = false)
private String appCode;
@Schema(description = "邮箱验证码(非数据库字段,仅用于绑定/修改邮箱时校验)") @Schema(description = "邮箱验证码(非数据库字段,仅用于绑定/修改邮箱时校验)")
@TableField(exist = false) @TableField(exist = false)
private String emailCode; private String emailCode;

View File

@@ -36,4 +36,13 @@ public interface RoleMenuMapper extends BaseMapper<RoleMenu> {
@InterceptorIgnore(tenantLine = "true") @InterceptorIgnore(tenantLine = "true")
List<Menu> listMenuByRoleIds(@Param("roleIds") List<Integer> roleIds, @Param("menuType") Integer menuType); List<Menu> listMenuByRoleIds(@Param("roleIds") List<Integer> roleIds, @Param("menuType") Integer menuType);
/**
* 跨租户查询角色菜单(忽略租户拦截器)
*
* @param tenantId 租户id
* @return List<RoleMenu>
*/
@InterceptorIgnore(tenantLine = "true")
List<RoleMenu> selectListAll(@Param("tenantId") Integer tenantId);
} }

View File

@@ -62,4 +62,13 @@ public interface UserRoleMapper extends BaseMapper<UserRole> {
*/ */
List<UserRole> selectListRel(@Param("param") UserRoleParam param); List<UserRole> selectListRel(@Param("param") UserRoleParam param);
/**
* 跨租户查询用户角色(忽略租户拦截器)
*
* @param tenantId 租户id
* @return List<UserRole>
*/
@InterceptorIgnore(tenantLine = "true")
List<UserRole> selectListAll(@Param("tenantId") Integer tenantId);
} }

View File

@@ -39,4 +39,9 @@
ORDER BY a.sort_number ORDER BY a.sort_number
</select> </select>
<!-- 跨租户查询角色菜单 -->
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.RoleMenu">
SELECT * FROM sys_role_menu WHERE tenant_id = #{tenantId}
</select>
</mapper> </mapper>

View File

@@ -64,4 +64,9 @@
<include refid="selectSql"></include> <include refid="selectSql"></include>
</select> </select>
<!-- 跨租户查询用户角色 -->
<select id="selectListAll" resultType="com.gxwebsoft.common.system.entity.UserRole">
SELECT * FROM sys_user_role WHERE tenant_id = #{tenantId}
</select>
</mapper> </mapper>

View File

@@ -21,6 +21,9 @@ public class EmailCaptchaParam implements Serializable {
@Schema(description = "邮箱") @Schema(description = "邮箱")
private String email; private String email;
@Schema(description = "邮箱验证码(校验时使用)")
private String code;
@Schema(description = "租户ID") @Schema(description = "租户ID")
private String tenantId; private String tenantId;

View File

@@ -0,0 +1,28 @@
package com.gxwebsoft.common.system.result;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* 检查邮箱返回结果
*
* @author WebSoft
* @since 2026-08-06
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "检查邮箱返回结果")
public class CheckEmailResult implements Serializable {
private static final long serialVersionUID = 1L;
@Schema(description = "是否已注册")
private Boolean isRegistered;
@Schema(description = "账号数量")
private Integer accountCount;
}

View File

@@ -13,6 +13,7 @@ import com.aliyun.oss.common.auth.DefaultCredentialProvider;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.gxwebsoft.common.core.utils.CommonUtil; import com.gxwebsoft.common.core.utils.CommonUtil;
import com.gxwebsoft.common.core.utils.MinioUtil;
import com.gxwebsoft.common.core.web.PageParam; import com.gxwebsoft.common.core.web.PageParam;
import com.gxwebsoft.common.core.web.PageResult; import com.gxwebsoft.common.core.web.PageResult;
import com.gxwebsoft.common.system.entity.Company; import com.gxwebsoft.common.system.entity.Company;
@@ -22,6 +23,8 @@ import com.gxwebsoft.common.system.param.FileRecordParam;
import com.gxwebsoft.common.system.service.CompanyService; import com.gxwebsoft.common.system.service.CompanyService;
import com.gxwebsoft.common.system.service.FileRecordService; import com.gxwebsoft.common.system.service.FileRecordService;
import com.gxwebsoft.common.system.service.SettingService; import com.gxwebsoft.common.system.service.SettingService;
import io.minio.MinioClient;
import io.minio.RemoveObjectArgs;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -96,24 +99,31 @@ public class FileRecordServiceImpl extends ServiceImpl<FileRecordMapper, FileRec
String accessKeyId = uploadConfig.getString("accessKeyId"); String accessKeyId = uploadConfig.getString("accessKeyId");
String accessKeySecret = uploadConfig.getString("accessKeySecret"); String accessKeySecret = uploadConfig.getString("accessKeySecret");
String uploadMethod = uploadConfig.getString("uploadMethod"); String uploadMethod = uploadConfig.getString("uploadMethod");
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret); // 规范化 endpoint去掉结尾斜杠避免 minio-java 拼接出双斜杠导致签名不匹配
// MinIO 兼容 S3 协议,需开启 PathStyle 访问 + V4 签名 endpoint = MinioUtil.normalizeEndpoint(endpoint);
OSS ossClient; boolean isMinio = "minio".equals(uploadMethod);
if ("minio".equals(uploadMethod)) { OSS ossClient = null;
ClientBuilderConfiguration minioConfig = new ClientBuilderConfiguration(); MinioClient minioClient = null;
// 3.17 已移除 setPathStyleAccess用 setExtractSettingFromEndpoint 让 SDK 对 MinIO 这类 if (isMinio) {
// 非阿里云 endpoint 自动按 path-style 访问,并从 endpoint 推导 region // MinIO 用官方 minio-java SDK原生 AWS4 + path-stylealiyun OSS SDK 无法对接 MinIO
minioConfig.setSignatureVersion(SignVersion.V4); // 显式指定 region=us-east-1跳过 getBucketLocation 自动探测(该探测 GET 会触发 SignatureDoesNotMatch
minioConfig.setExtractSettingFromEndpoint(true); minioClient = MinioUtil.buildClient(endpoint, accessKeyId, accessKeySecret);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider, minioConfig);
} else { } else {
CredentialsProvider credentialsProvider = new DefaultCredentialProvider(accessKeyId, accessKeySecret);
ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider); ossClient = new OSSClientBuilder().build(endpoint, credentialsProvider);
} }
for (FileRecord fileRecord : fileRecords) { for (FileRecord fileRecord : fileRecords) {
fileRecord.setPath(StrUtil.replace(fileRecord.getPath(), bucketDomain.concat("/"), "")); fileRecord.setPath(StrUtil.replace(fileRecord.getPath(), bucketDomain.concat("/"), ""));
try { try {
// 删除远程文件 // 删除远程文件
if (isMinio) {
minioClient.removeObject(RemoveObjectArgs.builder()
.bucket(bucketName)
.object(fileRecord.getPath())
.build());
} else {
ossClient.deleteObject(bucketName, fileRecord.getPath()); ossClient.deleteObject(bucketName, fileRecord.getPath());
}
// 释放空间大小 // 释放空间大小
if (fileRecord.getCompanyId() > 0) { if (fileRecord.getCompanyId() > 0) {
Company company = companyService.getById(fileRecord.getCompanyId()); Company company = companyService.getById(fileRecord.getCompanyId());

View File

@@ -10,8 +10,12 @@ import com.gxwebsoft.common.core.utils.DomainUtil;
import com.gxwebsoft.common.core.utils.RedisUtil; import com.gxwebsoft.common.core.utils.RedisUtil;
import com.gxwebsoft.common.system.entity.*; import com.gxwebsoft.common.system.entity.*;
import com.gxwebsoft.common.system.mapper.MenuMapper; import com.gxwebsoft.common.system.mapper.MenuMapper;
import com.gxwebsoft.common.system.mapper.RoleMapper;
import com.gxwebsoft.common.system.mapper.RoleMenuMapper;
import com.gxwebsoft.common.system.mapper.UserRoleMapper;
import com.gxwebsoft.common.system.mapper.TenantMapper; import com.gxwebsoft.common.system.mapper.TenantMapper;
import com.gxwebsoft.common.system.param.MenuParam; import com.gxwebsoft.common.system.param.MenuParam;
import com.gxwebsoft.common.system.param.RoleParam;
import com.gxwebsoft.common.system.service.*; import com.gxwebsoft.common.system.service.*;
import com.gxwebsoft.common.system.param.TenantParam; import com.gxwebsoft.common.system.param.TenantParam;
import com.gxwebsoft.common.core.web.ApiResult; import com.gxwebsoft.common.core.web.ApiResult;
@@ -26,6 +30,7 @@ import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet; import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -39,8 +44,6 @@ import java.util.stream.Collectors;
*/ */
@Service @Service
public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService { public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> implements TenantService {
// cms建站权限标识前缀
private static final String CMS_AUTHORITY_PREFIX = "cms:";
@Resource @Resource
private CompanyService companyService; private CompanyService companyService;
@@ -49,6 +52,12 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
@Resource @Resource
private MenuMapper menuMapper; private MenuMapper menuMapper;
@Resource @Resource
private RoleMapper roleMapper;
@Resource
private RoleMenuMapper roleMenuMapper;
@Resource
private UserRoleMapper userRoleMapper;
@Resource
private RoleMenuService roleMenuService; private RoleMenuService roleMenuService;
@Resource @Resource
private RoleService roleService; private RoleService roleService;
@@ -610,17 +619,38 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
} }
} }
// 发送邮件通知 // 发送邮件通知(按应用代号区分域名:前端注册时传入 appCode未知默认 site
String title = "恭喜!您的应用已创建成功"; // mp -> https://mp-{tid}.shoplnk.cn 后台 https://mp.websoft.top
String appUrl = "\r\n应用地址" + DomainUtil.getSiteUrl(company.getTid().toString()); // shop -> https://shop-{tid}.shoplnk.cn 后台 https://shop.websoft.top
String appName = "\r\n应用名称" + company.getShortName(); // site -> https://site-{tid}.sitelnk.cn 后台 https://site.websoft.top
String adminUrl = "\r\n后台管理" + DomainUtil.getAdminUrl(company.getTid().toString()); final Map<String, String[]> appDomainMap = new HashMap<>(3);
String account = "\r\n账号admin"; appDomainMap.put("mp", new String[]{"mp", "shoplnk.cn", "mp.websoft.top"});
String password = "\r\n密码" + company.getPassword(); appDomainMap.put("shop", new String[]{"shop", "shoplnk.cn", "shop.websoft.top"});
String content = title + appUrl + appName + adminUrl + account + password; appDomainMap.put("site", new String[]{"site", "sitelnk.cn", "site.websoft.top"});
final String appCode = StrUtil.isBlank(company.getAppCode()) ? "site" : company.getAppCode();
final String[] domainCfg = appDomainMap.getOrDefault(appCode, appDomainMap.get("site"));
final String sitePrefix = domainCfg[0];
final String siteDomain = domainCfg[1];
final String adminHost = domainCfg[2];
final String siteUrl = "https://" + sitePrefix + "-" + company.getTid() + "." + siteDomain;
final String adminUrl = "https://" + adminHost;
final String title = "您的企业官网已开通成功";
final StringBuilder content = new StringBuilder();
content.append("尊敬的用户:\r\n\r\n");
content.append("恭喜!您的企业官网「").append(company.getShortName()).append("」已开通成功。\r\n\r\n");
content.append("您可以使用以下信息登录管理后台,继续完善站点内容:\r\n\r\n");
content.append("• 官网地址:").append(siteUrl).append("\r\n");
content.append("• 后台地址:").append(adminUrl).append("\r\n");
content.append("• 登录账号:").append(company.getEmail()).append("\r\n");
content.append("• 登录密码:").append(company.getPassword()).append("\r\n\r\n");
content.append("安全提示:\r\n");
content.append("1. 请妥善保管登录密码,不要通过邮件、聊天工具发送给他人。\r\n");
content.append("2. 建议首次登录后立即进入「个人中心 > 修改密码」重置密码。\r\n\r\n");
content.append("点击登录后台:").append(adminUrl).append("\r\n\r\n");
content.append("如有疑问,请联系客服或访问帮助中心。");
// 发送邮件通知 // 发送邮件通知
if (company.getEmail() != null) { if (company.getEmail() != null) {
emailRecordService.sendEmail(title, content, company.getEmail(), company.getTid()); emailRecordService.sendEmail(title, content.toString(), company.getEmail(), company.getTid());
} }
return company; return company;
} }
@@ -654,21 +684,70 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
@Override @Override
@Transactional(rollbackFor = {Exception.class}) @Transactional(rollbackFor = {Exception.class})
public ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant) { public ApiResult<?> grantCmsPermission(Integer refTenant, Integer curTenant) {
// 1.当前租户的超级管理员角色, 必须带租户条件 if (refTenant == null || curTenant == null) {
final Role superAdmin = roleService.getOne(new LambdaQueryWrapper<Role>() return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户或当前租户为空");
.eq(Role::getRoleCode, "superAdmin")
.eq(Role::getTenantId, curTenant)
.last("limit 1"));
if (superAdmin == null) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "当前租户没有superAdmin角色, 无法授权");
} }
final Integer roleId = superAdmin.getRoleId(); if (refTenant.equals(curTenant)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户不能与当前租户相同");
}
final Map<String, Object> data = new HashMap<>();
data.put("refTenantId", refTenant);
data.put("tenantId", curTenant);
// 2.跨租户读取参考租户的全部未删除菜单 // ===== 1. 克隆角色 sys_role按 role_code 去重superAdmin/admin/user 已存在则复用)=====
final MenuParam refParam = new MenuParam(); final RoleParam refRoleParam = new RoleParam();
refParam.setTenantId(refTenant); refRoleParam.setTenantId(refTenant);
refParam.setDeleted(0); final List<Role> refRoles = roleMapper.selectListAll(refRoleParam);
final List<Menu> refMenus = menuMapper.getMenuByClone(refParam); if (CollectionUtils.isEmpty(refRoles)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的角色");
}
final RoleParam curRoleParam = new RoleParam();
curRoleParam.setTenantId(curTenant);
// role_code -> Role当前租户已存在的角色
final Map<String, Role> curRoleByCode = roleMapper.selectListAll(curRoleParam).stream()
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
// oldRoleId -> newRoleId 映射
final Map<Integer, Integer> roleIdMapping = new HashMap<>();
// 待新增角色(按 role_code 去重)
final Map<String, Role> toCreateByCode = new LinkedHashMap<>();
for (Role r : refRoles) {
final Role exist = curRoleByCode.get(r.getRoleCode());
if (exist != null) {
roleIdMapping.put(r.getRoleId(), exist.getRoleId());
} else if (!toCreateByCode.containsKey(r.getRoleCode())) {
final Role copy = new Role();
copy.setRoleCode(r.getRoleCode());
copy.setRoleName(r.getRoleName());
copy.setComments(r.getComments());
copy.setSortNumber(r.getSortNumber());
copy.setTenantId(curTenant);
toCreateByCode.put(r.getRoleCode(), copy);
}
}
int roleCreated = 0;
if (!toCreateByCode.isEmpty()) {
roleService.saveBatch(new ArrayList<>(toCreateByCode.values()));
// 重新读取当前租户角色,回填 oldRoleId -> newRoleId 映射
final Map<String, Role> refreshed = roleMapper.selectListAll(curRoleParam).stream()
.collect(Collectors.toMap(Role::getRoleCode, r -> r, (a, b) -> a));
for (Role r : refRoles) {
final Role nr = refreshed.get(r.getRoleCode());
if (nr != null) {
roleIdMapping.put(r.getRoleId(), nr.getRoleId());
curRoleByCode.putIfAbsent(r.getRoleCode(), nr);
}
}
roleCreated = toCreateByCode.size();
}
// 当前租户全部角色roleId -> Role含新克隆的供后续 role_code 对齐使用
final Map<Integer, Role> newRoleById = new HashMap<>();
curRoleByCode.values().forEach(r -> newRoleById.put(r.getRoleId(), r));
// ===== 2. 克隆菜单 sys_menu按层级复制父先于子标题/权限去重)=====
final MenuParam refMenuParam = new MenuParam();
refMenuParam.setTenantId(refTenant);
refMenuParam.setDeleted(0);
final List<Menu> refMenus = menuMapper.getMenuByClone(refMenuParam);
if (CollectionUtils.isEmpty(refMenus)) { if (CollectionUtils.isEmpty(refMenus)) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的菜单"); return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有可复制的菜单");
} }
@@ -676,42 +755,19 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
for (Menu menu : refMenus) { for (Menu menu : refMenus) {
refMenuMap.put(menu.getMenuId(), menu); refMenuMap.put(menu.getMenuId(), menu);
} }
final MenuParam curMenuParam = new MenuParam();
// 3.筛选cms:*子树, 权限标识以cms:开头的节点 + 其全部祖先, 保证父级完整 curMenuParam.setTenantId(curTenant);
final Set<Integer> cmsMenuIds = new HashSet<>(); curMenuParam.setDeleted(0);
for (Menu menu : refMenus) {
if (menu.getAuthority() == null || !menu.getAuthority().startsWith(CMS_AUTHORITY_PREFIX)) {
continue;
}
Menu current = menu;
// add返回false说明该节点及其祖先已收录, 无需继续向上追溯
while (current != null && cmsMenuIds.add(current.getMenuId())) {
final Integer parentId = current.getParentId();
current = (parentId == null || parentId == 0) ? null : refMenuMap.get(parentId);
}
}
if (cmsMenuIds.isEmpty()) {
return new ApiResult<>(Constants.RESULT_ERROR_CODE, "参考租户[" + refTenant + "]没有cms权限菜单");
}
// 4.当前租户已有菜单, 用于去重, 保证接口幂等
final MenuParam curParam = new MenuParam();
curParam.setTenantId(curTenant);
curParam.setDeleted(0);
final Map<String, Integer> curMenuKeys = new HashMap<>(); final Map<String, Integer> curMenuKeys = new HashMap<>();
for (Menu menu : menuMapper.getMenuByClone(curParam)) { for (Menu menu : menuMapper.getMenuByClone(curMenuParam)) {
curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId()); curMenuKeys.putIfAbsent(getMenuKey(menu), menu.getMenuId());
} }
// 5.按层级复制菜单, 父菜单先于子菜单插入
final List<Menu> sources = refMenus.stream() final List<Menu> sources = refMenus.stream()
.filter(d -> cmsMenuIds.contains(d.getMenuId()))
.sorted(Comparator.comparingInt((Menu d) -> getMenuDepth(d, refMenuMap))) .sorted(Comparator.comparingInt((Menu d) -> getMenuDepth(d, refMenuMap)))
.collect(Collectors.toList()); .collect(Collectors.toList());
final Map<Integer, Integer> menuIdMapping = new HashMap<>(); final Map<Integer, Integer> menuIdMapping = new HashMap<>();
int menuCount = 0; int menuCount = 0;
for (Menu source : sources) { for (Menu source : sources) {
// 已存在的菜单不重复插入, 直接复用其菜单id作为子菜单的父级
final Integer existsMenuId = curMenuKeys.get(getMenuKey(source)); final Integer existsMenuId = curMenuKeys.get(getMenuKey(source));
if (existsMenuId != null) { if (existsMenuId != null) {
menuIdMapping.put(source.getMenuId(), existsMenuId); menuIdMapping.put(source.getMenuId(), existsMenuId);
@@ -737,32 +793,88 @@ public class TenantServiceImpl extends ServiceImpl<TenantMapper, Tenant> impleme
menuCount++; menuCount++;
} }
// 6.绑定cms菜单到超级管理员角色, 已绑定的跳过 // ===== 3. 克隆角色菜单 sys_role_menuroleId/menuId 用映射转换,去重)=====
final Set<Integer> boundMenuIds = roleMenuService.list(new LambdaQueryWrapper<RoleMenu>() final Set<String> curRmKeys = roleMenuMapper.selectListAll(curTenant).stream()
.eq(RoleMenu::getRoleId, roleId)) .map(rm -> rm.getRoleId() + "_" + rm.getMenuId())
.stream().map(RoleMenu::getMenuId).collect(Collectors.toSet()); .collect(Collectors.toSet());
final List<RoleMenu> roleMenus = new ArrayList<>(); final List<RoleMenu> rmsToSave = new ArrayList<>();
for (Integer menuId : menuIdMapping.values()) { for (RoleMenu rm : roleMenuMapper.selectListAll(refTenant)) {
if (menuId == null || boundMenuIds.contains(menuId)) { final Integer newRoleId = roleIdMapping.get(rm.getRoleId());
final Integer newMenuId = menuIdMapping.get(rm.getMenuId());
if (newRoleId == null || newMenuId == null) {
continue; continue;
} }
final RoleMenu roleMenu = new RoleMenu(); final String key = newRoleId + "_" + newMenuId;
roleMenu.setRoleId(roleId); if (curRmKeys.contains(key)) {
roleMenu.setMenuId(menuId); continue;
roleMenu.setTenantId(curTenant);
roleMenus.add(roleMenu);
} }
if (!roleMenus.isEmpty()) { final RoleMenu copy = new RoleMenu();
roleMenuService.saveBatch(roleMenus); copy.setRoleId(newRoleId);
copy.setMenuId(newMenuId);
copy.setTenantId(curTenant);
rmsToSave.add(copy);
curRmKeys.add(key);
}
int roleMenuCreated = 0;
if (!rmsToSave.isEmpty()) {
roleMenuService.saveBatch(rmsToSave);
roleMenuCreated = rmsToSave.size();
} }
final Map<String, Object> data = new HashMap<>(); // ===== 4. 克隆用户角色 sys_user_role按 role_code 对齐当前租户用户,去重)=====
data.put("refTenantId", refTenant); final List<UserRole> curUserRoles = userRoleMapper.selectListAll(curTenant);
data.put("tenantId", curTenant); final Set<String> curUrKeys = curUserRoles.stream()
data.put("roleId", roleId); .map(ur -> ur.getUserId() + "_" + ur.getRoleId())
.collect(Collectors.toSet());
// 当前租户 userId -> 其 role_code 集合
final Map<Integer, Set<String>> userRoleCodes = new HashMap<>();
for (UserRole ur : curUserRoles) {
final Role role = newRoleById.get(ur.getRoleId());
if (role == null) {
continue;
}
userRoleCodes.computeIfAbsent(ur.getUserId(), k -> new HashSet<>()).add(role.getRoleCode());
}
final List<UserRole> ursToSave = new ArrayList<>();
for (UserRole ur : userRoleMapper.selectListAll(refTenant)) {
final Integer newRoleId = roleIdMapping.get(ur.getRoleId());
if (newRoleId == null) {
continue;
}
final Role newRole = newRoleById.get(newRoleId);
if (newRole == null) {
continue;
}
final String roleCode = newRole.getRoleCode();
// 找当前租户中 role_code 相同的用户,建立绑定
for (Map.Entry<Integer, Set<String>> e : userRoleCodes.entrySet()) {
if (!e.getValue().contains(roleCode)) {
continue;
}
final String key = e.getKey() + "_" + newRoleId;
if (curUrKeys.contains(key)) {
continue;
}
final UserRole copy = new UserRole();
copy.setUserId(e.getKey());
copy.setRoleId(newRoleId);
copy.setTenantId(curTenant);
ursToSave.add(copy);
curUrKeys.add(key);
}
}
int userRoleCreated = 0;
if (!ursToSave.isEmpty()) {
userRoleService.saveBatch(ursToSave);
userRoleCreated = ursToSave.size();
}
data.put("roleTotal", refRoles.size());
data.put("roleCreated", roleCreated);
data.put("menuTotal", menuIdMapping.size()); data.put("menuTotal", menuIdMapping.size());
data.put("menuCreated", menuCount); data.put("menuCreated", menuCount);
data.put("roleMenuCreated", roleMenus.size()); data.put("roleMenuCreated", roleMenuCreated);
data.put("userRoleCreated", userRoleCreated);
return new ApiResult<>(Constants.RESULT_OK_CODE, "授权成功", data); return new ApiResult<>(Constants.RESULT_OK_CODE, "授权成功", data);
} }