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);
+ }
+ }
+
}