From 47cabcbcfc580367c2f41747079759b0b9c9efa0 Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Thu, 11 Dec 2025 08:18:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=A7=9F=E6=88=B7=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/多租户用户管理改进说明.md | 228 ++++++++++++++++++ pom.xml | 10 + sql/mysql/tenant_add.sql | 25 +- .../common/tenant/TenantContextHolder.java | 9 +- .../boot/config/TenantDynamicFieldConfig.java | 76 ++++++ .../com/youlai/boot/config/WebMvcConfig.java | 23 ++ .../TenantValidationInterceptor.java | 86 +++++++ .../plugin/mybatis/MyMetaObjectHandler.java | 25 +- .../system/controller/TenantController.java | 63 +++-- .../system/mapper/TenantSwitchLogMapper.java | 15 ++ .../system/model/entity/TenantSwitchLog.java | 80 ++++++ .../boot/system/service/TenantService.java | 14 +- .../service/impl/TenantServiceImpl.java | 87 +++++++ .../system/service/impl/UserServiceImpl.java | 92 ++++++- 14 files changed, 789 insertions(+), 44 deletions(-) create mode 100644 docs/多租户用户管理改进说明.md create mode 100644 src/main/java/com/youlai/boot/config/TenantDynamicFieldConfig.java create mode 100644 src/main/java/com/youlai/boot/core/interceptor/TenantValidationInterceptor.java create mode 100644 src/main/java/com/youlai/boot/system/mapper/TenantSwitchLogMapper.java create mode 100644 src/main/java/com/youlai/boot/system/model/entity/TenantSwitchLog.java diff --git a/docs/多租户用户管理改进说明.md b/docs/多租户用户管理改进说明.md new file mode 100644 index 00000000..84417f78 --- /dev/null +++ b/docs/多租户用户管理改进说明.md @@ -0,0 +1,228 @@ +# 多租户用户管理改进说明 + +## 改进概述 + +本次改进实现了在用户管理中自动维护 `sys_user_tenant` 关联表,支持单租户和多租户两种模式的无缝切换。 + +## 核心改进 + +### 1. 用户新增时自动创建租户关联 + +**修改文件**: `UserServiceImpl.saveUser()` + +**逻辑**: +- 创建用户后,判断是否启用多租户(通过 `youlai.tenant.enabled` 配置) +- 如果启用,自动向 `sys_user_tenant` 表插入关联记录 +- 新用户默认设置为该租户的默认租户(`is_default=1`) + +```java +// 新增用户 +boolean result = this.save(entity); + +if (result) { + // 保存用户角色 + userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds()); + + // 如果启用多租户,保存用户租户关联 + if (Boolean.TRUE.equals(tenantProperties.getEnabled())) { + saveUserTenantRelation(entity.getId(), entity.getTenantId(), true); + } +} +``` + +### 2. 用户更新时同步租户关联 + +**修改文件**: `UserServiceImpl.updateUser()` + +**逻辑**: +- 比较用户的旧租户ID和新租户ID +- 如果租户发生变更: + - 删除旧的租户关联记录 + - 创建新的租户关联记录 + +```java +// 如果启用多租户且租户发生变更,更新用户租户关联 +if (Boolean.TRUE.equals(tenantProperties.getEnabled())) { + Long newTenantId = entity.getTenantId(); + if (newTenantId != null && !newTenantId.equals(oldTenantId)) { + // 删除旧的租户关联 + if (oldTenantId != null) { + userTenantMapper.delete(...); + } + // 保存新的租户关联 + saveUserTenantRelation(userId, newTenantId, true); + } +} +``` + +### 3. 用户删除时清理租户关联 + +**修改文件**: `UserServiceImpl.deleteUsers()` + +**逻辑**: +- 删除用户后,自动清理 `sys_user_tenant` 表中的关联记录 +- 避免产生孤立数据 + +```java +boolean result = this.removeByIds(ids); + +// 如果启用多租户,删除用户租户关联 +if (result && Boolean.TRUE.equals(tenantProperties.getEnabled())) { + for (Long userId : ids) { + userTenantMapper.delete(...); + log.info("删除用户租户关联:userId={}", userId); + } +} +``` + +### 4. 新增私有方法处理关联逻辑 + +**新增方法**: `saveUserTenantRelation()` + +**功能**: +- 检查关联是否已存在 +- 存在则更新 `is_default` 标识 +- 不存在则插入新记录 +- 添加详细日志记录 + +## 配置说明 + +### 启用多租户 + +在 `application-dev.yml` 中配置: + +```yaml +youlai: + tenant: + enabled: true # 设置为 true 启用多租户 + column: tenant_id + default-tenant-id: 1 +``` + +### 禁用多租户 + +```yaml +youlai: + tenant: + enabled: false # 设置为 false 禁用多租户 +``` + +当禁用多租户时: +- ✅ 不会自动创建/更新/删除 `sys_user_tenant` 记录 +- ✅ 只使用 `sys_user.tenant_id` 字段 +- ✅ 零成本切换,无需修改代码 + +## 数据库设计 + +### sys_user 表 +```sql +ALTER TABLE `sys_user` +ADD COLUMN `tenant_id` bigint DEFAULT 1 COMMENT '租户ID' AFTER `id`, +ADD INDEX `idx_tenant_id` (`tenant_id`); +``` + +### sys_user_tenant 表 +```sql +CREATE TABLE `sys_user_tenant` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint NOT NULL COMMENT '用户ID', + `tenant_id` bigint NOT NULL COMMENT '租户ID', + `is_default` tinyint DEFAULT '0' COMMENT '是否默认租户(1-是 0-否)', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_user_tenant` (`user_id`, `tenant_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_tenant_id` (`tenant_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户租户关联表(多租户模式)'; +``` + +## 数据初始化 + +执行 SQL 脚本时,会自动为现有用户创建租户关联: + +```sql +INSERT INTO `sys_user_tenant` (`user_id`, `tenant_id`, `is_default`) +SELECT `id`, 1, 1 FROM `sys_user` WHERE `is_deleted` = 0 +ON DUPLICATE KEY UPDATE `is_default` = 1; +``` + +## 使用场景 + +### 场景1:单租户模式 +- 配置:`youlai.tenant.enabled = false` +- 用户只属于一个租户 +- 数据完全隔离 +- 不需要租户切换功能 + +### 场景2:多租户模式 +- 配置:`youlai.tenant.enabled = true` +- 用户可以属于多个租户 +- 可以在不同租户间切换 +- 通过 `sys_user_tenant` 表管理关联关系 + +## 关键优势 + +1. **自动化管理**: 创建/更新/删除用户时自动维护关联表 +2. **灵活切换**: 通过配置即可在单租户和多租户模式间切换 +3. **数据一致性**: 确保 `sys_user.tenant_id` 和 `sys_user_tenant` 表数据同步 +4. **幂等操作**: 支持重复执行,避免重复插入 +5. **完整日志**: 每次操作都有日志记录,便于追踪问题 + +## 注意事项 + +1. **事务处理**: 用户的增删改操作都已添加事务注解 `@Transactional` +2. **空值检查**: `saveUserTenantRelation()` 方法会检查参数是否为空 +3. **幂等性**: 插入前会检查记录是否已存在 +4. **配置优先**: 所有操作都基于 `tenantProperties.getEnabled()` 判断 + +## 测试建议 + +### 测试场景1:多租户模式下创建用户 +1. 设置 `youlai.tenant.enabled = true` +2. 在租户A下创建用户"张三" +3. 验证: + - `sys_user` 表插入记录,`tenant_id=A` + - `sys_user_tenant` 表插入记录,`user_id=张三, tenant_id=A, is_default=1` + +### 测试场景2:多租户模式下更新用户租户 +1. 将用户"张三"从租户A转移到租户B +2. 验证: + - `sys_user` 表更新,`tenant_id=B` + - `sys_user_tenant` 表删除旧记录 (A),插入新记录 (B) + +### 测试场景3:多租户模式下删除用户 +1. 删除用户"张三" +2. 验证: + - `sys_user` 表标记为删除 + - `sys_user_tenant` 表删除关联记录 + +### 测试场景4:单租户模式 +1. 设置 `youlai.tenant.enabled = false` +2. 创建/更新/删除用户 +3. 验证: + - 只操作 `sys_user` 表 + - 不操作 `sys_user_tenant` 表 + +## 修改文件清单 + +- ✅ `UserServiceImpl.java` - 添加多租户关联维护逻辑 +- ✅ `tenant_add.sql` - 数据库表结构和初始化脚本 +- ✅ `TenantProperties.java` - 多租户配置类(已存在) +- ✅ `UserTenantMapper.java` - MyBatis Mapper(已存在) +- ✅ `UserTenant.java` - 实体类(已存在) + +## 向后兼容性 + +- ✅ 默认配置为 `enabled: false`,不影响现有单租户系统 +- ✅ 现有代码无需修改,只需调整配置文件即可启用多租户 +- ✅ 数据库升级脚本支持多次执行(幂等) + +## 总结 + +本次改进完善了多租户用户管理机制,实现了: +- 自动维护用户租户关联关系 +- 支持单/多租户模式灵活切换 +- 保证数据一致性和完整性 +- 提供详细的操作日志 + +系统现在可以零成本在单租户和多租户模式间切换,只需修改配置文件即可。 diff --git a/pom.xml b/pom.xml index 3a51ac4b..93e3889a 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,9 @@ 4.7.7.B 2.9.3 + + + 2.14.5 @@ -75,6 +78,13 @@ ${hutool.version} + + + com.alibaba + transmittable-thread-local + ${transmittable-thread-local.version} + + org.projectlombok diff --git a/sql/mysql/tenant_add.sql b/sql/mysql/tenant_add.sql index 6e6a4f3d..ec367255 100644 --- a/sql/mysql/tenant_add.sql +++ b/sql/mysql/tenant_add.sql @@ -39,7 +39,30 @@ INSERT INTO `sys_tenant` (`id`, `name`, `code`, `status`, `create_time`) VALUES (1, '默认租户', 'DEFAULT', 1, NOW()); -- ============================================ --- 2. 创建用户租户关联表(支持一个用户属于多个租户) +-- 2. 创建租户切换审计日志表 +-- ============================================ +DROP TABLE IF EXISTS `sys_tenant_switch_log`; +CREATE TABLE `sys_tenant_switch_log` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `user_id` bigint NOT NULL COMMENT '用户ID', + `username` varchar(64) COMMENT '用户名', + `from_tenant_id` bigint COMMENT '原租户ID', + `from_tenant_name` varchar(100) COMMENT '原租户名称', + `to_tenant_id` bigint NOT NULL COMMENT '目标租户ID', + `to_tenant_name` varchar(100) COMMENT '目标租户名称', + `switch_time` datetime NOT NULL COMMENT '切换时间', + `ip_address` varchar(50) COMMENT 'IP地址', + `user_agent` varchar(500) COMMENT '浏览器信息', + `status` tinyint DEFAULT '1' COMMENT '切换状态(1-成功 0-失败)', + `fail_reason` varchar(255) COMMENT '失败原因', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_switch_time` (`switch_time`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户切换审计日志表'; + +-- ============================================ +-- 3. 创建用户租户关联表(支持一个用户属于多个租户) -- ============================================ DROP TABLE IF EXISTS `sys_user_tenant`; CREATE TABLE `sys_user_tenant` ( diff --git a/src/main/java/com/youlai/boot/common/tenant/TenantContextHolder.java b/src/main/java/com/youlai/boot/common/tenant/TenantContextHolder.java index fd674b31..e80e55e6 100644 --- a/src/main/java/com/youlai/boot/common/tenant/TenantContextHolder.java +++ b/src/main/java/com/youlai/boot/common/tenant/TenantContextHolder.java @@ -1,11 +1,13 @@ package com.youlai.boot.common.tenant; +import com.alibaba.ttl.TransmittableThreadLocal; import lombok.extern.slf4j.Slf4j; /** * 租户上下文工具类 *

- * 使用 ThreadLocal 存储当前线程的租户ID,确保线程安全 + * 使用 TransmittableThreadLocal 存储当前线程的租户ID,确保线程安全 + * 支持异步任务、线程池、消息队列等场景的上下文传递 *

* * @author Ray.Hao @@ -16,13 +18,14 @@ public class TenantContextHolder { /** * 租户ID线程本地变量 + * 使用 TransmittableThreadLocal 支持父子线程和线程池场景的值传递 */ - private static final ThreadLocal TENANT_ID_HOLDER = new ThreadLocal<>(); + private static final TransmittableThreadLocal TENANT_ID_HOLDER = new TransmittableThreadLocal<>(); /** * 忽略租户标志(用于某些场景下临时跳过租户过滤) */ - private static final ThreadLocal IGNORE_TENANT_HOLDER = new ThreadLocal<>(); + private static final TransmittableThreadLocal IGNORE_TENANT_HOLDER = new TransmittableThreadLocal<>(); /** * 设置当前租户ID diff --git a/src/main/java/com/youlai/boot/config/TenantDynamicFieldConfig.java b/src/main/java/com/youlai/boot/config/TenantDynamicFieldConfig.java new file mode 100644 index 00000000..59d9cf05 --- /dev/null +++ b/src/main/java/com/youlai/boot/config/TenantDynamicFieldConfig.java @@ -0,0 +1,76 @@ +package com.youlai.boot.config; + +import com.baomidou.mybatisplus.core.metadata.TableFieldInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfo; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.youlai.boot.config.property.TenantProperties; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Field; +import java.util.List; + +/** + * 多租户动态字段配置 + *

+ * 在多租户模式启用时,动态修改 BaseEntity 中 tenant_id 字段的 exist 属性为 true + * 这样可以实现: + * - 单租户模式:tenant_id exist=false,不映射该字段,兼容没有该字段的表 + * - 多租户模式:tenant_id exist=true,自动填充租户ID到INSERT/UPDATE语句 + *

+ * + * @author Ray.Hao + * @since 3.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "youlai.tenant", name = "enabled", havingValue = "true") +public class TenantDynamicFieldConfig implements InitializingBean { + + private final TenantProperties tenantProperties; + + @Override + public void afterPropertiesSet() { + log.info("多租户模式已启用,开始动态配置 tenant_id 字段映射..."); + + int modifiedCount = 0; + List tableInfos = TableInfoHelper.getTableInfos(); + + for (TableInfo tableInfo : tableInfos) { + // 检查是否是忽略的表 + String tableName = tableInfo.getTableName(); + if (tenantProperties.getIgnoreTables().contains(tableName)) { + log.debug("表 {} 在忽略列表中,跳过 tenant_id 字段配置", tableName); + continue; + } + + // 查找 tenant_id 字段 + TableFieldInfo tenantField = tableInfo.getFieldList().stream() + .filter(field -> tenantProperties.getColumn().equals(field.getColumn())) + .findFirst() + .orElse(null); + + if (tenantField != null) { + try { + // 通过反射修改 exist 属性为 true + Field existField = TableFieldInfo.class.getDeclaredField("exist"); + existField.setAccessible(true); + existField.set(tenantField, true); + + modifiedCount++; + log.debug("已为表 {} 启用 tenant_id 字段映射", tableName); + } catch (NoSuchFieldException | IllegalAccessException e) { + log.warn("修改表 {} 的 tenant_id 字段配置失败: {}", tableName, e.getMessage()); + } + } else { + log.warn("表 {} 未找到 tenant_id 字段,请检查实体类是否继承 BaseEntity", tableName); + } + } + + log.info("多租户字段配置完成,共修改 {} 张表", modifiedCount); + } +} diff --git a/src/main/java/com/youlai/boot/config/WebMvcConfig.java b/src/main/java/com/youlai/boot/config/WebMvcConfig.java index a8082ea9..fae163f3 100644 --- a/src/main/java/com/youlai/boot/config/WebMvcConfig.java +++ b/src/main/java/com/youlai/boot/config/WebMvcConfig.java @@ -7,17 +7,21 @@ import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer; import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer; +import com.youlai.boot.core.interceptor.TenantValidationInterceptor; import jakarta.validation.Validation; import jakarta.validation.Validator; import jakarta.validation.ValidatorFactory; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.validator.HibernateValidator; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.config.AutowireCapableBeanFactory; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.converter.HttpMessageConverter; import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; import org.springframework.validation.beanvalidation.SpringConstraintValidatorFactory; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.math.BigInteger; @@ -39,6 +43,9 @@ public class WebMvcConfig implements WebMvcConfigurer { private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + @Autowired(required = false) + private TenantValidationInterceptor tenantValidationInterceptor; + /** * 配置消息转换器 * @@ -78,6 +85,22 @@ public class WebMvcConfig implements WebMvcConfigurer { * @param autowireCapableBeanFactory 用于注入 SpringConstraintValidatorFactory * @return Validator 实例 */ + /** + * 配置拦截器 + * + * @param registry 拦截器注册器 + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + // 注册租户校验拦截器(仅在多租户模式启用时生效) + if (tenantValidationInterceptor != null) { + registry.addInterceptor(tenantValidationInterceptor) + .addPathPatterns("/api/**") + .order(2); // 在认证拦截器之后执行 + log.info("租户校验拦截器已注册"); + } + } + @Bean public Validator validator(final AutowireCapableBeanFactory autowireCapableBeanFactory) { try (ValidatorFactory validatorFactory = Validation.byProvider(HibernateValidator.class) diff --git a/src/main/java/com/youlai/boot/core/interceptor/TenantValidationInterceptor.java b/src/main/java/com/youlai/boot/core/interceptor/TenantValidationInterceptor.java new file mode 100644 index 00000000..7ef57da7 --- /dev/null +++ b/src/main/java/com/youlai/boot/core/interceptor/TenantValidationInterceptor.java @@ -0,0 +1,86 @@ +package com.youlai.boot.core.interceptor; + +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.common.tenant.TenantContextHolder; +import com.youlai.boot.config.property.TenantProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * 租户ID强制校验拦截器 + *

+ * 对于需要租户隔离的接口,强制要求携带有效的租户ID + * 防止恶意用户通过不携带租户ID来访问默认租户数据 + *

+ * + * @author Ray.Hao + * @since 3.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "youlai.tenant", name = "enabled", havingValue = "true") +public class TenantValidationInterceptor implements HandlerInterceptor { + + private final TenantProperties tenantProperties; + + /** + * 白名单路径:这些路径不需要租户ID校验 + */ + private static final List WHITELIST_PATHS = Arrays.asList( + "/api/v1/auth/login", + "/api/v1/auth/logout", + "/api/v1/auth/captcha", + "/api/v1/tenant/list", + "/doc.html", + "/v3/api-docs", + "/swagger-ui", + "/favicon.ico", + "/error" + ); + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { + String requestPath = request.getRequestURI(); + + // 检查是否在白名单中 + if (isWhitelistPath(requestPath)) { + return true; + } + + // 检查租户ID是否存在 + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + log.warn("请求路径 {} 缺少租户ID", requestPath); + response.setStatus(HttpServletResponse.SC_BAD_REQUEST); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write(String.format( + "{\"code\":\"%s\",\"msg\":\"租户ID不能为空,请联系管理员\"}", + ResultCode.BAD_REQUEST.getCode() + )); + return false; + } + + // 可选:校验租户是否有效(需要注入 TenantService) + // 这里暂时只校验租户ID不为空 + + return true; + } + + /** + * 检查路径是否在白名单中 + */ + private boolean isWhitelistPath(String requestPath) { + return WHITELIST_PATHS.stream() + .anyMatch(requestPath::startsWith); + } +} diff --git a/src/main/java/com/youlai/boot/plugin/mybatis/MyMetaObjectHandler.java b/src/main/java/com/youlai/boot/plugin/mybatis/MyMetaObjectHandler.java index 14196424..23852ae0 100644 --- a/src/main/java/com/youlai/boot/plugin/mybatis/MyMetaObjectHandler.java +++ b/src/main/java/com/youlai/boot/plugin/mybatis/MyMetaObjectHandler.java @@ -28,6 +28,10 @@ public class MyMetaObjectHandler implements MetaObjectHandler { /** * 新增填充创建时间、更新时间和租户ID + *

+ * 多租户模式下,tenant_id 字段的 exist 属性会被 TenantDynamicFieldConfig 动态设置为 true, + * 因此这里的 strictInsertFill 可以正常工作 + *

* * @param metaObject 元数据 */ @@ -37,23 +41,16 @@ public class MyMetaObjectHandler implements MetaObjectHandler { this.strictUpdateFill(metaObject, "updateTime", LocalDateTime::now, LocalDateTime.class); // 如果启用了多租户,自动填充租户ID - // 注意:由于 BaseEntity 中 tenantId 字段使用了 exist = false(避免单租户模式报错) - // 在启用多租户时,需要通过反射动态修改字段的 exist 属性,或者直接设置值 - // 但 MyBatis-Plus 的字段映射是静态的,无法动态修改 - // 因此,我们使用 strictInsertFill,它会自动处理字段映射 - // 如果字段不存在(exist = false),strictInsertFill 会跳过,不会报错 if (tenantProperties != null && Boolean.TRUE.equals(tenantProperties.getEnabled())) { Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + // 如果上下文中没有租户ID,使用默认租户ID + tenantId = tenantProperties.getDefaultTenantId(); + } if (tenantId != null) { - // 使用数据库字段名(tenant_id)进行填充 - // 注意:由于 exist = false,这个填充不会写入数据库 - // 但多租户的数据隔离是通过 TenantLineHandler 自动添加 WHERE 条件实现的 - // 所以这里只需要设置实体对象的属性值即可(用于业务逻辑) - String propertyName = "tenantId"; - if (metaObject.hasGetter(propertyName)) { - // 直接设置值到实体对象,不依赖字段映射 - metaObject.setValue(propertyName, tenantId); - } + // 使用 strictInsertFill 自动填充租户ID + // 注意:由于 TenantDynamicFieldConfig 已将 exist 设置为 true,这里可以正常填充 + this.strictInsertFill(metaObject, "tenantId", () -> tenantId, Long.class); } } } diff --git a/src/main/java/com/youlai/boot/system/controller/TenantController.java b/src/main/java/com/youlai/boot/system/controller/TenantController.java index dfd0ff3a..723c6b86 100644 --- a/src/main/java/com/youlai/boot/system/controller/TenantController.java +++ b/src/main/java/com/youlai/boot/system/controller/TenantController.java @@ -8,6 +8,7 @@ import com.youlai.boot.system.service.TenantService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -71,6 +72,7 @@ public class TenantController { * 切换租户 *

* 切换当前用户的租户上下文,需要验证用户是否有权限访问该租户 + * 并记录审计日志 *

* * @param tenantId 目标租户ID @@ -79,32 +81,49 @@ public class TenantController { @Operation(summary = "切换租户") @PostMapping("/switch/{tenantId}") public Result switchTenant( - @Parameter(description = "租户ID") @PathVariable Long tenantId + @Parameter(description = "租户ID") @PathVariable Long tenantId, + HttpServletRequest request ) { Long userId = SecurityUtils.getUserId(); - log.info("用户 {} 请求切换租户到 {}", userId, tenantId); + Long fromTenantId = TenantContextHolder.getTenantId(); + + log.info("用户 {} 请求切换租户:{} -> {}", userId, fromTenantId, tenantId); - // 验证用户是否有权限访问该租户 - boolean hasPermission = tenantService.hasTenantPermission(userId, tenantId); - if (!hasPermission) { - log.warn("用户 {} 无权限访问租户 {}", userId, tenantId); - return Result.failed("无权限访问该租户"); + try { + // 验证用户是否有权限访问该租户 + boolean hasPermission = tenantService.hasTenantPermission(userId, tenantId); + if (!hasPermission) { + log.warn("用户 {} 无权限访问租户 {}", userId, tenantId); + // 记录失败日志 + tenantService.recordTenantSwitch(userId, fromTenantId, tenantId, false, "无权限访问该租户", request); + return Result.failed("无权限访问该租户"); + } + + // 验证租户是否存在且正常 + TenantVO tenant = tenantService.getTenantById(tenantId); + if (tenant == null) { + tenantService.recordTenantSwitch(userId, fromTenantId, tenantId, false, "租户不存在", request); + return Result.failed("租户不存在"); + } + if (tenant.getStatus() == null || tenant.getStatus() != 1) { + tenantService.recordTenantSwitch(userId, fromTenantId, tenantId, false, "租户已禁用", request); + return Result.failed("租户已禁用"); + } + + // 设置新的租户上下文 + TenantContextHolder.setTenantId(tenantId); + + // 记录成功日志 + tenantService.recordTenantSwitch(userId, fromTenantId, tenantId, true, null, request); + + log.info("用户 {} 成功切换租户到 {}", userId, tenantId); + + return Result.success(tenant); + } catch (Exception e) { + log.error("用户 {} 切换租户失败", userId, e); + tenantService.recordTenantSwitch(userId, fromTenantId, tenantId, false, e.getMessage(), request); + return Result.failed("切换租户失败:" + e.getMessage()); } - - // 验证租户是否存在且正常 - TenantVO tenant = tenantService.getTenantById(tenantId); - if (tenant == null) { - return Result.failed("租户不存在"); - } - if (tenant.getStatus() == null || tenant.getStatus() != 1) { - return Result.failed("租户已禁用"); - } - - // 设置新的租户上下文 - TenantContextHolder.setTenantId(tenantId); - log.info("用户 {} 成功切换租户到 {}", userId, tenantId); - - return Result.success(tenant); } } diff --git a/src/main/java/com/youlai/boot/system/mapper/TenantSwitchLogMapper.java b/src/main/java/com/youlai/boot/system/mapper/TenantSwitchLogMapper.java new file mode 100644 index 00000000..62cd36ec --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/TenantSwitchLogMapper.java @@ -0,0 +1,15 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.system.model.entity.TenantSwitchLog; +import org.apache.ibatis.annotations.Mapper; + +/** + * 租户切换审计日志 Mapper + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Mapper +public interface TenantSwitchLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/youlai/boot/system/model/entity/TenantSwitchLog.java b/src/main/java/com/youlai/boot/system/model/entity/TenantSwitchLog.java new file mode 100644 index 00000000..d455d1e1 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/TenantSwitchLog.java @@ -0,0 +1,80 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 租户切换审计日志实体 + * + * @author Ray.Hao + * @since 3.0.0 + */ +@Data +@TableName("sys_tenant_switch_log") +public class TenantSwitchLog { + + /** + * 主键ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 用户ID + */ + private Long userId; + + /** + * 用户名 + */ + private String username; + + /** + * 原租户ID + */ + private Long fromTenantId; + + /** + * 原租户名称 + */ + private String fromTenantName; + + /** + * 目标租户ID + */ + private Long toTenantId; + + /** + * 目标租户名称 + */ + private String toTenantName; + + /** + * 切换时间 + */ + private LocalDateTime switchTime; + + /** + * IP地址 + */ + private String ipAddress; + + /** + * 浏览器信息 + */ + private String userAgent; + + /** + * 切换状态(1-成功 0-失败) + */ + private Integer status; + + /** + * 失败原因 + */ + private String failReason; +} diff --git a/src/main/java/com/youlai/boot/system/service/TenantService.java b/src/main/java/com/youlai/boot/system/service/TenantService.java index 36dc36f7..130ad916 100644 --- a/src/main/java/com/youlai/boot/system/service/TenantService.java +++ b/src/main/java/com/youlai/boot/system/service/TenantService.java @@ -46,5 +46,17 @@ public interface TenantService extends IService { * @return true-有权限,false-无权限 */ boolean hasTenantPermission(Long userId, Long tenantId); -} + /** + * 记录租户切换审计日志 + * + * @param userId 用户ID + * @param fromTenantId 原租户ID + * @param toTenantId 目标租户ID + * @param success 是否成功 + * @param failReason 失败原因 + * @param request HTTP请求对象 + */ + void recordTenantSwitch(Long userId, Long fromTenantId, Long toTenantId, + boolean success, String failReason, jakarta.servlet.http.HttpServletRequest request); +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/TenantServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/TenantServiceImpl.java index 8d11aa49..14bda285 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/TenantServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/TenantServiceImpl.java @@ -1,19 +1,26 @@ package com.youlai.boot.system.service.impl; +import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.youlai.boot.common.tenant.TenantContextHolder; import com.youlai.boot.system.mapper.TenantMapper; +import com.youlai.boot.system.mapper.TenantSwitchLogMapper; +import com.youlai.boot.system.mapper.UserMapper; import com.youlai.boot.system.mapper.UserTenantMapper; import com.youlai.boot.system.model.entity.Tenant; +import com.youlai.boot.system.model.entity.TenantSwitchLog; +import com.youlai.boot.system.model.entity.User; import com.youlai.boot.system.model.entity.UserTenant; import com.youlai.boot.system.model.vo.TenantVO; import com.youlai.boot.system.service.TenantService; +import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.BeanUtils; import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; import java.util.stream.Collectors; @@ -29,6 +36,8 @@ import java.util.stream.Collectors; public class TenantServiceImpl extends ServiceImpl implements TenantService { private final UserTenantMapper userTenantMapper; + private final TenantSwitchLogMapper tenantSwitchLogMapper; + private final UserMapper userMapper; @Override public List getTenantListByUserId(Long userId) { @@ -121,5 +130,83 @@ public class TenantServiceImpl extends ServiceImpl impleme TenantContextHolder.setIgnoreTenant(false); } } + + @Override + public void recordTenantSwitch(Long userId, Long fromTenantId, Long toTenantId, + boolean success, String failReason, HttpServletRequest request) { + try { + // 临时忽略租户过滤,确保日志可以写入 + TenantContextHolder.setIgnoreTenant(true); + + // 创建审计日志 + TenantSwitchLog log = new TenantSwitchLog(); + log.setUserId(userId); + log.setFromTenantId(fromTenantId); + log.setToTenantId(toTenantId); + log.setSwitchTime(LocalDateTime.now()); + log.setStatus(success ? 1 : 0); + log.setFailReason(failReason); + + // 获取用户名 + if (userId != null) { + User user = userMapper.selectById(userId); + if (user != null) { + log.setUsername(user.getUsername()); + } + } + + // 获取租户名称 + if (fromTenantId != null) { + Tenant fromTenant = this.getById(fromTenantId); + if (fromTenant != null) { + log.setFromTenantName(fromTenant.getName()); + } + } + if (toTenantId != null) { + Tenant toTenant = this.getById(toTenantId); + if (toTenant != null) { + log.setToTenantName(toTenant.getName()); + } + } + + // 获取IP地址和User-Agent + if (request != null) { + log.setIpAddress(getIpAddress(request)); + log.setUserAgent(request.getHeader("User-Agent")); + } + + // 保存审计日志 + tenantSwitchLogMapper.insert(log); + } catch (Exception e) { + // 记录日志失败不应影响业务,仅记录错误 + Slf4j.getLogger(this.getClass()).error("记录租户切换日志失败", e); + } finally { + TenantContextHolder.setIgnoreTenant(false); + } + } + + /** + * 获取客户端IP地址 + */ + private String getIpAddress(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (StrUtil.isBlank(ip) || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + // 处理多级代理的情况 + if (ip != null && ip.contains(",")) { + ip = ip.split(",")[0].trim(); + } + return ip; + } } diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index 749cfba1..401dc492 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -76,6 +76,10 @@ public class UserServiceImpl extends ServiceImpl implements Us private final UserConverter userConverter; + private final com.youlai.boot.config.property.TenantProperties tenantProperties; + + private final com.youlai.boot.system.mapper.UserTenantMapper userTenantMapper; + /** * 获取用户分页列表 * @@ -118,6 +122,7 @@ public class UserServiceImpl extends ServiceImpl implements Us * @return true|false */ @Override + @Transactional(rollbackFor = Exception.class) public boolean saveUser(UserForm userForm) { String username = userForm.getUsername(); @@ -139,6 +144,11 @@ public class UserServiceImpl extends ServiceImpl implements Us if (result) { // 保存用户角色 userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds()); + + // 如果启用多租户,保存用户租户关联 + if (Boolean.TRUE.equals(tenantProperties.getEnabled())) { + saveUserTenantRelation(entity.getId(), entity.getTenantId(), true); + } } return result; } @@ -151,7 +161,7 @@ public class UserServiceImpl extends ServiceImpl implements Us * @return true|false */ @Override - @Transactional + @Transactional(rollbackFor = Exception.class) public boolean updateUser(Long userId, UserForm userForm) { String username = userForm.getUsername(); @@ -162,6 +172,10 @@ public class UserServiceImpl extends ServiceImpl implements Us ); Assert.isTrue(count == 0, "用户名已存在"); + // 获取原用户信息,用于比较租户是否变更 + User oldUser = this.getById(userId); + Long oldTenantId = oldUser != null ? oldUser.getTenantId() : null; + // form -> entity User entity = userConverter.toEntity(userForm); entity.setUpdateBy(SecurityUtils.getUserId()); @@ -172,6 +186,23 @@ public class UserServiceImpl extends ServiceImpl implements Us if (result) { // 保存用户角色 userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds()); + + // 如果启用多租户且租户发生变更,更新用户租户关联 + if (Boolean.TRUE.equals(tenantProperties.getEnabled())) { + Long newTenantId = entity.getTenantId(); + if (newTenantId != null && !newTenantId.equals(oldTenantId)) { + // 删除旧的租户关联 + if (oldTenantId != null) { + userTenantMapper.delete( + new LambdaQueryWrapper() + .eq(com.youlai.boot.system.model.entity.UserTenant::getUserId, userId) + .eq(com.youlai.boot.system.model.entity.UserTenant::getTenantId, oldTenantId) + ); + } + // 保存新的租户关联 + saveUserTenantRelation(userId, newTenantId, true); + } + } } return result; } @@ -183,14 +214,28 @@ public class UserServiceImpl extends ServiceImpl implements Us * @return true|false */ @Override + @Transactional(rollbackFor = Exception.class) public boolean deleteUsers(String idsStr) { Assert.isTrue(StrUtil.isNotBlank(idsStr), "删除的用户数据为空"); // 逻辑删除 List ids = Arrays.stream(idsStr.split(",")) .map(Long::parseLong) .collect(Collectors.toList()); - return this.removeByIds(ids); - + + boolean result = this.removeByIds(ids); + + // 如果启用多租户,删除用户租户关联 + if (result && Boolean.TRUE.equals(tenantProperties.getEnabled())) { + for (Long userId : ids) { + userTenantMapper.delete( + new LambdaQueryWrapper() + .eq(com.youlai.boot.system.model.entity.UserTenant::getUserId, userId) + ); + log.info("删除用户租户关联:userId={}", userId); + } + } + + return result; } /** @@ -686,4 +731,45 @@ public class UserServiceImpl extends ServiceImpl implements Us return userConverter.toOptions(list); } + /** + * 保存用户租户关联关系 + *

+ * 仅在启用多租户时调用此方法 + *

+ * + * @param userId 用户ID + * @param tenantId 租户ID + * @param isDefault 是否为默认租户 + */ + private void saveUserTenantRelation(Long userId, Long tenantId, boolean isDefault) { + if (userId == null || tenantId == null) { + log.warn("用户ID或租户ID为空,跳过保存用户租户关联"); + return; + } + + // 检查关联是否已存在 + com.youlai.boot.system.model.entity.UserTenant existingRelation = userTenantMapper.selectOne( + new LambdaQueryWrapper() + .eq(com.youlai.boot.system.model.entity.UserTenant::getUserId, userId) + .eq(com.youlai.boot.system.model.entity.UserTenant::getTenantId, tenantId) + ); + + if (existingRelation != null) { + // 如果已存在,更新 is_default 标识 + if (isDefault && existingRelation.getIsDefault() != 1) { + existingRelation.setIsDefault(1); + userTenantMapper.updateById(existingRelation); + log.info("更新用户租户关联:userId={}, tenantId={}, isDefault=true", userId, tenantId); + } + } else { + // 不存在则新增 + com.youlai.boot.system.model.entity.UserTenant userTenant = new com.youlai.boot.system.model.entity.UserTenant(); + userTenant.setUserId(userId); + userTenant.setTenantId(tenantId); + userTenant.setIsDefault(isDefault ? 1 : 0); + userTenantMapper.insert(userTenant); + log.info("保存用户租户关联:userId={}, tenantId={}, isDefault={}", userId, tenantId, isDefault); + } + } + }