多租户代码框架
This commit is contained in:
@@ -34,10 +34,10 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
@MapperScan("com.rj.mapper")
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
public class AISmartCard20250803Application {
|
||||
public class AISmartCard20251230Application {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AISmartCard20250803Application.class, args);
|
||||
SpringApplication.run(AISmartCard20251230Application.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -2,35 +2,82 @@ package com.rj.config;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.DbType;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import com.rj.tenant.TenantContextHolder;
|
||||
import net.sf.jsqlparser.expression.Expression;
|
||||
import net.sf.jsqlparser.expression.StringValue;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* MyBatis Plus 配置类
|
||||
*
|
||||
* @author 李中华
|
||||
* @since 2025-08-08
|
||||
*
|
||||
* <p>包含:</p>
|
||||
* <ul>
|
||||
* <li>多租户拦截器:根据当前线程中的租户ID自动为SQL拼接 tenant_id 条件;</li>
|
||||
* <li>分页插件;</li>
|
||||
* <li>乐观锁插件。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Configuration
|
||||
@EnableTransactionManagement
|
||||
public class MybatisPlusConfig {
|
||||
|
||||
/**
|
||||
* 分页插件配置
|
||||
* MyBatis-Plus 主拦截器配置
|
||||
*/
|
||||
@Bean
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||
public MybatisPlusInterceptor mybatisPlusInterceptor(TenantLineHandler tenantLineHandler) {
|
||||
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||
|
||||
// 分页插件
|
||||
|
||||
// 1. 多租户插件(需优先添加)
|
||||
interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(tenantLineHandler));
|
||||
|
||||
// 2. 分页插件
|
||||
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||
|
||||
// 乐观锁插件
|
||||
|
||||
// 3. 乐观锁插件
|
||||
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
|
||||
|
||||
|
||||
return interceptor;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 多租户处理器:定义如何获取租户ID、租户字段名以及忽略的表
|
||||
*/
|
||||
@Bean
|
||||
public TenantLineHandler tenantLineHandler() {
|
||||
// 需要忽略多租户的表(不自动拼接 tenant_id)
|
||||
Set<String> ignoreTables = new HashSet<>();
|
||||
ignoreTables.add("tenant"); // 租户表本身
|
||||
ignoreTables.add("industry_tags"); // 行业标签(示例:如认为是公共字典)
|
||||
|
||||
return new TenantLineHandler() {
|
||||
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
String tenantId = TenantContextHolder.getTenantId();
|
||||
// 没有租户ID时,返回 null 由 MyBatis-Plus 决定处理策略,通常为不过滤或抛错(视版本而定)
|
||||
return tenantId == null ? null : new StringValue(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTenantIdColumn() {
|
||||
return "tenant_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
// 忽略名单内的表不做租户隔离
|
||||
return ignoreTables.contains(tableName);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.rj.config;
|
||||
|
||||
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||
import com.rj.tenant.TenantContextHolder;
|
||||
import org.apache.ibatis.reflection.MetaObject;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* MyBatis-Plus 公共字段自动填充处理器
|
||||
*
|
||||
* <p>
|
||||
* 仅负责在插入时为带有 tenantId 字段的实体自动填充当前租户ID,<br/>
|
||||
* 更新时不修改 tenantId,确保租户ID一旦写入不可被业务层随意修改。
|
||||
* </p>
|
||||
*/
|
||||
@Component
|
||||
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
|
||||
|
||||
private static final String TENANT_FIELD = "tenantId";
|
||||
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
// 仅在实体中存在 tenantId 字段且当前值为空时进行填充
|
||||
if (metaObject.hasSetter(TENANT_FIELD)) {
|
||||
Object currentValue = getFieldValByName(TENANT_FIELD, metaObject);
|
||||
if (currentValue == null) {
|
||||
String tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId != null) {
|
||||
this.strictInsertFill(metaObject, TENANT_FIELD, String.class, tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
// 不自动更新 tenantId,保持租户ID稳定
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
110
src/main/java/com/rj/config/TenantFilter.java
Normal file
110
src/main/java/com/rj/config/TenantFilter.java
Normal file
@@ -0,0 +1,110 @@
|
||||
package com.rj.config;
|
||||
|
||||
import com.rj.tenant.TenantContextHolder;
|
||||
import com.rj.entity.sys.User;
|
||||
import com.rj.service.sys.IUserService;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* 多租户过滤器:
|
||||
* <p>
|
||||
* - 从 HTTP Header 中读取租户ID:X-Tenant-Id<br/>
|
||||
* - 将租户ID写入 {@link TenantContextHolder}<br/>
|
||||
* - 在请求结束时清理线程变量,避免线程复用造成的串租户问题
|
||||
* </p>
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
public class TenantFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantFilter.class);
|
||||
|
||||
/**
|
||||
* Header 名称常量,便于前后端约定与维护
|
||||
*/
|
||||
public static final String TENANT_HEADER = "X-Tenant-Id";
|
||||
|
||||
/**
|
||||
* 认证 Header 名,约定为 Bearer Token
|
||||
*/
|
||||
public static final String AUTH_HEADER = "Authorization";
|
||||
|
||||
private final IUserService userService;
|
||||
|
||||
public TenantFilter(IUserService userService) {
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String headerTenantId = request.getHeader(TENANT_HEADER);
|
||||
String finalTenantId = null;
|
||||
|
||||
// 1. 从 Authorization 头中解析 token,并查出用户的租户ID
|
||||
String authHeader = request.getHeader(AUTH_HEADER);
|
||||
String tokenTenantId = null;
|
||||
if (authHeader != null && !authHeader.isEmpty()) {
|
||||
String token = extractToken(authHeader);
|
||||
if (token != null && !token.isEmpty()) {
|
||||
User user = userService.getUserByToken(token);
|
||||
if (user != null) {
|
||||
tokenTenantId = user.getTenantId();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 2. 计算本次请求实际使用的租户ID,并校验一致性
|
||||
if (tokenTenantId != null && headerTenantId != null && !headerTenantId.isEmpty()
|
||||
&& !tokenTenantId.equals(headerTenantId)) {
|
||||
// Header 与 Token 中的租户不一致,记录告警日志(也可以按需直接拒绝请求)
|
||||
log.warn("Tenant mismatch between token and header. tokenTenantId={}, headerTenantId={}", tokenTenantId, headerTenantId);
|
||||
}
|
||||
|
||||
if (tokenTenantId != null) {
|
||||
finalTenantId = tokenTenantId;
|
||||
} else if (headerTenantId != null && !headerTenantId.isEmpty()) {
|
||||
finalTenantId = headerTenantId;
|
||||
}
|
||||
|
||||
// 设置租户上下文(允许为 null:如公共接口或未登录情况)
|
||||
TenantContextHolder.setTenantId(finalTenantId);
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
// 无论请求是否成功,都要清理线程变量
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Authorization 头中提取实际的 token 值。
|
||||
* 支持形如 "Bearer xxx" 或直接传 token 的方式。
|
||||
*/
|
||||
private String extractToken(String authHeader) {
|
||||
if (authHeader == null) {
|
||||
return null;
|
||||
}
|
||||
authHeader = authHeader.trim();
|
||||
if (authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
return authHeader.substring(7).trim();
|
||||
}
|
||||
return authHeader;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ public class AudioManagement implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "录音名称")
|
||||
@TableField("recording_name")
|
||||
private String recordingName;
|
||||
|
||||
@@ -33,6 +33,10 @@ public class AudioManagementStatistics implements Serializable {
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "门店ID")
|
||||
@TableField("dealership_id")
|
||||
private Long dealershipId;
|
||||
|
||||
@@ -28,6 +28,10 @@ public class AudioTextAnalysisFurniture implements Serializable {
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "音频管理ID/父级ID")
|
||||
@TableField("parent_id")
|
||||
private String parentId;
|
||||
|
||||
@@ -28,6 +28,10 @@ public class AudioTextAnalysisSop implements Serializable {
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "父ID,UUID,用于树状结构关联")
|
||||
@TableField("parent_id")
|
||||
private String parentId;
|
||||
|
||||
@@ -29,6 +29,10 @@ public class CommunicationRecord implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "客户姓名")
|
||||
@TableField("customer_name")
|
||||
private String customerName;
|
||||
|
||||
@@ -29,6 +29,10 @@ public class CustomerManagement implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "客户姓名")
|
||||
@TableField("customer_name")
|
||||
private String customerName;
|
||||
|
||||
@@ -21,6 +21,12 @@ public class CustomerProfileAnalysis {
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* AI分析记录ID
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,10 @@ public class Dealership implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "经销商名称")
|
||||
@TableField("dealership_name")
|
||||
private String dealershipName;
|
||||
|
||||
@@ -28,6 +28,12 @@ public class FaceDetectLog implements Serializable {
|
||||
@TableId(value = "id", type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 请求ID
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,10 @@ public class ProjectManagement implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "所属经销商ID")
|
||||
@TableField("dealership_id")
|
||||
private String dealershipId;
|
||||
|
||||
@@ -30,6 +30,10 @@ public class SalesManagement implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "销售名称")
|
||||
@TableField("sales_name")
|
||||
private String salesName;
|
||||
|
||||
@@ -23,6 +23,12 @@ public class TtsRequestLog {
|
||||
*/
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 请求时间
|
||||
|
||||
@@ -28,6 +28,12 @@ public class VideoImageAnalysis implements Serializable {
|
||||
@TableId(value = "id", type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 使用的AI模型
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,10 @@ public class VideoManagement implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "视频名称")
|
||||
@TableField("video_name")
|
||||
private String videoName;
|
||||
|
||||
@@ -27,6 +27,12 @@ public class VideoSynthesisLog {
|
||||
@TableId(type = IdType.ASSIGN_UUID)
|
||||
private String id;
|
||||
|
||||
/**
|
||||
* 租户ID
|
||||
*/
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
/**
|
||||
* 请求ID(唯一)
|
||||
*/
|
||||
|
||||
@@ -29,6 +29,10 @@ public class YhyAudioUploadLog implements Serializable {
|
||||
@TableId("id")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "文件路径")
|
||||
@TableField("file_path")
|
||||
private String filePath;
|
||||
|
||||
@@ -33,4 +33,8 @@ public class Role implements Serializable {
|
||||
@TableField("role_name")
|
||||
private String roleName;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
}
|
||||
|
||||
@@ -30,6 +30,10 @@ public class User implements Serializable {
|
||||
@TableId("user_id")
|
||||
private Integer userId;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
@Schema(description = "用户名称")
|
||||
@TableField("user_name")
|
||||
private String userName;
|
||||
|
||||
@@ -33,4 +33,8 @@ public class UserRole implements Serializable {
|
||||
@TableField("role_id")
|
||||
private Integer roleId;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
|
||||
}
|
||||
|
||||
@@ -1,17 +1,40 @@
|
||||
package com.rj.mapper.sys;
|
||||
|
||||
import com.rj.entity.sys.User;
|
||||
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.rj.entity.sys.User;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
/**
|
||||
* <p>
|
||||
* 用户表 Mapper 接口
|
||||
* </p>
|
||||
*
|
||||
* <p>
|
||||
* 说明:
|
||||
* - 登录认证/根据 token 查询用户时,需要跨租户查询用户信息,因此这些方法通过
|
||||
* {@link InterceptorIgnore} 显式跳过多租户拦截器,不在 SQL 中追加 tenant_id 条件。
|
||||
* - 其他通过 MyBatis-Plus 内置 CRUD 产生的 SQL 仍然会自动带上 tenant_id 条件。
|
||||
* </p>
|
||||
*
|
||||
* @author 系统生成
|
||||
* @since 2025-08-07
|
||||
*/
|
||||
public interface UserMapper extends BaseMapper<User> {
|
||||
|
||||
/**
|
||||
* 根据用户名查询用户(登录用),不走多租户拦截器。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT * FROM user WHERE user_name = #{userName} LIMIT 1")
|
||||
User selectByUserNameIgnoreTenant(@Param("userName") String userName);
|
||||
|
||||
/**
|
||||
* 根据 token 查询用户(token 验证、获取当前用户信息),不走多租户拦截器。
|
||||
*/
|
||||
@InterceptorIgnore(tenantLine = "true")
|
||||
@Select("SELECT * FROM user WHERE token = #{token} LIMIT 1")
|
||||
User selectByTokenIgnoreTenant(@Param("token") String token);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ public class LoginResponse {
|
||||
@Schema(description = "登录时间")
|
||||
private String loginTime;
|
||||
|
||||
@Schema(description = "租户ID")
|
||||
private String tenantId;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IU
|
||||
|
||||
@Override
|
||||
public LoginResponse login(LoginRequest loginRequest) {
|
||||
// 根据用户名查询用户
|
||||
User user = getUserByUserName(loginRequest.getUserName());
|
||||
// 根据用户名查询用户(登录时不走多租户拦截器)
|
||||
User user = this.baseMapper.selectByUserNameIgnoreTenant(loginRequest.getUserName());
|
||||
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
@@ -55,12 +55,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IU
|
||||
loginResponse.setAvatar(user.getAvatar());
|
||||
loginResponse.setToken(token);
|
||||
loginResponse.setLoginTime(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
|
||||
loginResponse.setTenantId(user.getTenantId());
|
||||
|
||||
return loginResponse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public User getUserByUserName(String userName) {
|
||||
// 供其他业务使用的通用查询,保持按租户隔离
|
||||
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(User::getUserName, userName);
|
||||
return this.getOne(queryWrapper);
|
||||
@@ -68,16 +70,14 @@ public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements IU
|
||||
|
||||
@Override
|
||||
public User getUserByToken(String token) {
|
||||
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(User::getToken, token);
|
||||
return this.getOne(queryWrapper);
|
||||
// 用于 token 验证等场景,需跨租户查询用户
|
||||
return this.baseMapper.selectByTokenIgnoreTenant(token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public User getUserInfoByToken(String token) {
|
||||
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(User::getToken, token);
|
||||
return this.getOne(queryWrapper);
|
||||
// 获取用户详细信息,同样使用跨租户查询
|
||||
return this.baseMapper.selectByTokenIgnoreTenant(token);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
50
src/main/java/com/rj/tenant/TenantContextHolder.java
Normal file
50
src/main/java/com/rj/tenant/TenantContextHolder.java
Normal file
@@ -0,0 +1,50 @@
|
||||
package com.rj.tenant;
|
||||
|
||||
/**
|
||||
* 多租户上下文持有器,用于在当前线程中保存和获取租户ID。
|
||||
*
|
||||
* <p>
|
||||
* 典型使用场景:
|
||||
* <ul>
|
||||
* <li>在请求入口(过滤器 / 拦截器)中根据 Header / Token 解析出租户ID并设置;</li>
|
||||
* <li>MyBatis-Plus 的 TenantLineHandler 从此处读取当前租户;</li>
|
||||
* <li>业务代码中偶尔需要获取当前请求所在的租户。</li>
|
||||
* </ul>
|
||||
* 使用完成后务必调用 {@link #clear()} 清理,避免线程复用导致租户串用。
|
||||
* </p>
|
||||
*/
|
||||
public final class TenantContextHolder {
|
||||
|
||||
private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();
|
||||
|
||||
private TenantContextHolder() {
|
||||
// 工具类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前线程的租户ID。
|
||||
*
|
||||
* @param tenantId 租户ID,允许为 null(表示无租户上下文)
|
||||
*/
|
||||
public static void setTenantId(String tenantId) {
|
||||
TENANT_ID_HOLDER.set(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前线程的租户ID。
|
||||
*
|
||||
* @return 当前租户ID,可能为 null
|
||||
*/
|
||||
public static String getTenantId() {
|
||||
return TENANT_ID_HOLDER.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理当前线程保存的租户ID,防止线程复用导致的租户串用。
|
||||
*/
|
||||
public static void clear() {
|
||||
TENANT_ID_HOLDER.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
573
src/main/sql/多租户技术方案.md
Normal file
573
src/main/sql/多租户技术方案.md
Normal file
@@ -0,0 +1,573 @@
|
||||
多租户改造实施方案文档(AIDriveEEForAudio)
|
||||
1. 背景与目标
|
||||
当前现状
|
||||
技术栈:Spring Boot 3.5 + MyBatis-Plus + MyBatis XML + MySQL。
|
||||
ORM:@TableName + BaseMapper 为主,XML 主要用于 resultMap 与列列表。
|
||||
现有系统未引入任何 租户字段/多租户插件/租户上下文,为 单租户系统。
|
||||
改造目标
|
||||
在不大幅重构现有业务的前提下,引入稳定可扩展的 多租户能力,实现不同租户数据的逻辑隔离;
|
||||
对现有 CRUD 影响尽量小,大部分业务代码可复用;
|
||||
为后续扩展租户级限流、权限、配置等能力奠定基础。
|
||||
2. 多租户模式与设计原则
|
||||
2.1 多租户模式选择
|
||||
本项目推荐采用:
|
||||
模式:单库单 schema + 每张业务表增加 tenant_id 字段 + MyBatis-Plus 多租户插件自动拼接租户条件。
|
||||
理由:
|
||||
不需要为每个租户单独建库或动态数据源,改造成本可控;
|
||||
利用 MP 官方 TenantLineInnerInterceptor,绝大多数查询/更新可自动加上 tenant_id 条件;
|
||||
便于后续做运营侧的跨租户统计(可选择性绕过多租户拦截器)。
|
||||
2.2 租户边界与业务含义
|
||||
推荐按 企业 / 经销商 / 机构 维度作为租户边界,例如:
|
||||
一个 4S 店/经销商/医院/公司 = 一个租户;
|
||||
一个租户下包含多个用户、销售、设备、项目等。
|
||||
租户维度对象:
|
||||
用户(user)归属一个租户;
|
||||
门店/项目/客户/音频记录等业务实体,均挂在某个 tenant_id 下。
|
||||
3. 数据库层改造方案
|
||||
3.1 新增租户表
|
||||
新表:tenant
|
||||
字段建议:
|
||||
id:主键(BIGINT 或 VARCHAR(36),与 Java 实现保持一致)
|
||||
tenant_code:租户编码(如公司英文缩写、唯一)
|
||||
tenant_name:租户名称
|
||||
status:状态(启用/禁用)
|
||||
create_time / update_time:创建与更新时间
|
||||
其他扩展字段:联系人、电话、过期时间
|
||||
|
||||
CREATE TABLE `tenant` (
|
||||
`id` VARCHAR(36) NOT NULL COMMENT '租户主键ID',
|
||||
`tenant_code` VARCHAR(64) NOT NULL COMMENT '租户编码,唯一',
|
||||
`tenant_name` VARCHAR(128) NOT NULL COMMENT '租户名称',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:1-启用,0-禁用',
|
||||
`contact_name` VARCHAR(64) DEFAULT NULL COMMENT '联系人姓名',
|
||||
`contact_phone`VARCHAR(32) DEFAULT NULL COMMENT '联系人电话',
|
||||
`expire_time` DATETIME DEFAULT NULL COMMENT '租户过期时间',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_code` (`tenant_code`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租户表';
|
||||
|
||||
|
||||
|
||||
3.2 业务表增加 tenant_id 字段
|
||||
需挂租户的表(示例,实际以 SQL 目录为准扩展):
|
||||
音频类:
|
||||
audio_management、audio_segment、audio_management_statistics、audio_text_analysis_furniture、audio_text_analysis_sop、video_image_analysis、video_management、video_synthesis_log 等。
|
||||
客户与销售类:
|
||||
customer_management、customer_profile_analysis、communication_record、sales_management 等。
|
||||
门店与项目类:
|
||||
dealership、project_management。
|
||||
日志与其他:
|
||||
yhy_audio_upload_log、heartbeat_log、tts_request_log、face_detect_log 等业务相关日志。
|
||||
用户与权限:
|
||||
user、user_role、role 等(至少 user 需有 tenant_id 字段)。
|
||||
字段规范建议:
|
||||
字段名统一:tenant_id;
|
||||
字段类型:与 tenant.id 保持一致(推荐 BIGINT 或 VARCHAR(36));
|
||||
约束:
|
||||
第一阶段:允许 NULL(兼容旧数据);
|
||||
数据迁移完成后:设置为 NOT NULL;
|
||||
索引:
|
||||
大表添加复合索引:
|
||||
如:customer_management 增加 INDEX idx_cm_tenant_dealership (tenant_id, dealership_id)
|
||||
如:audio_management 增加 INDEX idx_am_tenant_ctime (tenant_id, create_time)
|
||||
|
||||
-- ======================
|
||||
-- 音频类
|
||||
-- ======================
|
||||
ALTER TABLE `audio_management`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_am_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `audio_segment`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_as_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `audio_management_statistics`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_ams_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `audio_text_analysis_furniture`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_ataf_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `audio_text_analysis_sop`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_atas_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `video_image_analysis`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_via_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `video_management`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_vm_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `video_synthesis_log`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_vsl_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
|
||||
-- ======================
|
||||
-- 客户与销售类
|
||||
-- ======================
|
||||
ALTER TABLE `customer_management`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_cm_tenant_dealership` (`tenant_id`, `dealership_id`);
|
||||
|
||||
ALTER TABLE `customer_profile_analysis`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_cpa_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `communication_record`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_cr_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `sales_management`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_sm_tenant_sales` (`tenant_id`, `sales_id`);
|
||||
|
||||
|
||||
-- ======================
|
||||
-- 门店与项目类
|
||||
-- ======================
|
||||
ALTER TABLE `dealership`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_dealer_tenant` (`tenant_id`);
|
||||
|
||||
ALTER TABLE `project_management`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_pm_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
|
||||
-- ======================
|
||||
-- 日志与其他
|
||||
-- ======================
|
||||
ALTER TABLE `yhy_audio_upload_log`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_yhy_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `heartbeat_log`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_hb_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `tts_request_log`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_tts_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
ALTER TABLE `face_detect_log`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_fdl_tenant_ctime` (`tenant_id`, `create_time`);
|
||||
|
||||
|
||||
-- ======================
|
||||
-- 用户与权限
|
||||
-- ======================
|
||||
ALTER TABLE `user`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_user_tenant` (`tenant_id`);
|
||||
|
||||
ALTER TABLE `user_role`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_ur_tenant_role` (`tenant_id`, `role_id`);
|
||||
|
||||
ALTER TABLE `role`
|
||||
ADD COLUMN `tenant_id` VARCHAR(36) NULL COMMENT '租户ID' AFTER `id`,
|
||||
ADD INDEX `idx_role_tenant` (`tenant_id`);
|
||||
|
||||
|
||||
|
||||
3.3 历史数据迁移策略
|
||||
插入一个默认租户,如:DEFAULT_TENANT:
|
||||
INSERT INTO tenant (id, tenant_code, tenant_name, status, ...) VALUES (...);
|
||||
对所有新增了 tenant_id 的表执行数据补齐:
|
||||
UPDATE 表名 SET tenant_id = 'DEFAULT_TENANT_ID' WHERE tenant_id IS NULL;
|
||||
需要细分历史数据进入多个租户时,可后续再做拆分迁移脚本。
|
||||
-- 1. 插入默认租户(示例)
|
||||
INSERT INTO `tenant` (
|
||||
`id`,
|
||||
`tenant_code`,
|
||||
`tenant_name`,
|
||||
`status`,
|
||||
`contact_name`,
|
||||
`contact_phone`,
|
||||
`expire_time`,
|
||||
`create_time`,
|
||||
`update_time`
|
||||
) VALUES (
|
||||
'DEFAULT_TENANT_ID', -- 建议改成真实的 UUID 或雪花ID
|
||||
'DEFAULT_TENANT',
|
||||
'默认租户',
|
||||
1,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NOW(),
|
||||
NOW()
|
||||
);
|
||||
|
||||
|
||||
-- 2. 为所有新增了 tenant_id 的表补齐历史数据
|
||||
-- 音频类
|
||||
UPDATE `audio_management` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `audio_segment` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `audio_management_statistics` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `audio_text_analysis_furniture` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `audio_text_analysis_sop` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `video_image_analysis` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `video_management` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `video_synthesis_log` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
|
||||
-- 客户与销售类
|
||||
UPDATE `customer_management` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `customer_profile_analysis` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `communication_record` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `sales_management` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
|
||||
-- 门店与项目类
|
||||
UPDATE `dealership` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `project_management` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
|
||||
-- 日志与其他
|
||||
UPDATE `yhy_audio_upload_log` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `heartbeat_log` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `tts_request_log` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `face_detect_log` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
|
||||
-- 用户与权限
|
||||
UPDATE `user` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `user_role` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
UPDATE `role` SET `tenant_id` = 'DEFAULT_TENANT_ID' WHERE `tenant_id` IS NULL;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
4. 应用层多租户实现方案
|
||||
4.1 实体类增加 tenantId 字段
|
||||
以 CustomerManagement 为例:
|
||||
在 com.rj.entity.CustomerManagement 中增加:
|
||||
@Schema(description = "租户ID")
|
||||
@TableField("tenant_id")
|
||||
private String tenantId;
|
||||
@Schema(description = "租户ID")@TableField("tenant_id")private String tenantId;
|
||||
对所有挂租户的实体(如 AudioManagement、Dealership、ProjectManagement、SalesManagement 等)增加同名字段,映射 tenant_id。
|
||||
> 原则:和数据库字段一一对应,命名统一为 tenantId + @TableField("tenant_id"),方便在 MyBatis-Plus 插件中统一处理。
|
||||
|
||||
|
||||
4.2 租户上下文设计(TenantContextHolder)
|
||||
在 com.rj.common 或新包 com.rj.tenant 下新增 TenantContextHolder:
|
||||
功能:
|
||||
存放当前请求的租户 ID(基于 ThreadLocal);
|
||||
提供 set/get/clear 三个静态方法;
|
||||
供:
|
||||
TenantLineHandler 从中读取租户;
|
||||
部分业务中获取当前租户信息。
|
||||
|
||||
public final class TenantContextHolder {
|
||||
|
||||
private static final ThreadLocal<String> TENANT_ID_HOLDER = new ThreadLocal<>();
|
||||
|
||||
private TenantContextHolder() {
|
||||
// 工具类,禁止实例化
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前线程的租户ID。
|
||||
*
|
||||
* @param tenantId 租户ID,允许为 null(表示无租户上下文)
|
||||
*/
|
||||
public static void setTenantId(String tenantId) {
|
||||
TENANT_ID_HOLDER.set(tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前线程的租户ID。
|
||||
*
|
||||
* @return 当前租户ID,可能为 null
|
||||
*/
|
||||
public static String getTenantId() {
|
||||
return TENANT_ID_HOLDER.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理当前线程保存的租户ID,防止线程复用导致的租户串用。
|
||||
*/
|
||||
public static void clear() {
|
||||
TENANT_ID_HOLDER.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
4.3 请求到租户的映射方式
|
||||
推荐方案:HTTP Header 传递租户 ID。
|
||||
前端在每次请求头中带上:X-Tenant-Id: <当前租户ID>;
|
||||
该租户 ID 一般来自:
|
||||
登录时从用户表查出的 tenant_id;
|
||||
登录成功后前端缓存(或由 token 中解析得到)。
|
||||
4.4 请求过滤器 / 拦截器
|
||||
在 com.rj.config 新增 TenantFilter(或使用 Spring MVC HandlerInterceptor):
|
||||
获取请求头 X-Tenant-Id;
|
||||
校验租户 ID 是否存在、是否合法(可选校验是否为启用状态);
|
||||
成功则 TenantContextHolder.setTenantId(tenantId);
|
||||
在 finally 阶段调用 TenantContextHolder.clear() 防止线程复用污染。
|
||||
> 要求:任何 Mapper/Service 层执行 SQL 前,当前线程中必须已经设置好了租户 ID。
|
||||
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
public class TenantFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantFilter.class);
|
||||
|
||||
/**
|
||||
* Header 名称常量,便于前后端约定与维护
|
||||
*/
|
||||
public static final String TENANT_HEADER = "X-Tenant-Id";
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String tenantId = request.getHeader(TENANT_HEADER);
|
||||
|
||||
try {
|
||||
if (tenantId != null && !tenantId.isEmpty()) {
|
||||
TenantContextHolder.setTenantId(tenantId);
|
||||
} else {
|
||||
// 允许为 null 的情况:例如某些公共接口或尚未登录
|
||||
TenantContextHolder.setTenantId(null);
|
||||
}
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
// 无论请求是否成功,都要清理线程变量
|
||||
TenantContextHolder.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
5. MyBatis-Plus 多租户插件集成
|
||||
5.1 当前配置情况
|
||||
com.rj.config.MybatisPlusConfig 中已有:
|
||||
PaginationInnerInterceptor(分页插件)
|
||||
OptimisticLockerInnerInterceptor(乐观锁)
|
||||
暂未配置多租户拦截器。
|
||||
5.2 引入 TenantLineInnerInterceptor
|
||||
思路:在 MybatisPlusInterceptor 中加入一个 TenantLineInnerInterceptor,并实现 TenantLineHandler 接口,定义:
|
||||
当前租户 ID 的获取方式;
|
||||
租户字段名(统一为 tenant_id);
|
||||
哪些表不需要租户隔离。
|
||||
配置要点:
|
||||
添加顺序:一般先添加多租户拦截器,再添加分页拦截器;
|
||||
在 ignoreTable 中配置哪些表不参与多租户,例如:
|
||||
tenant(租户表自身)
|
||||
公共字典表/行业标签表等(如认为是全局共享)。
|
||||
> 效果:所有走 MyBatis-Plus 的查询/更新语句,在生成 SQL 时自动追加 tenant_id = 当前租户 条件,实现物理数据隔离。
|
||||
|
||||
/**
|
||||
* 多租户处理器:定义如何获取租户ID、租户字段名以及忽略的表
|
||||
*/
|
||||
@Bean
|
||||
public TenantLineHandler tenantLineHandler() {
|
||||
// 需要忽略多租户的表(不自动拼接 tenant_id)
|
||||
Set<String> ignoreTables = new HashSet<>();
|
||||
ignoreTables.add("tenant"); // 租户表本身
|
||||
ignoreTables.add("industry_tags"); // 行业标签(示例:如认为是公共字典)
|
||||
|
||||
return new TenantLineHandler() {
|
||||
|
||||
@Override
|
||||
public Expression getTenantId() {
|
||||
String tenantId = TenantContextHolder.getTenantId();
|
||||
// 没有租户ID时,返回 null 由 MyBatis-Plus 决定处理策略,通常为不过滤或抛错(视版本而定)
|
||||
return tenantId == null ? null : new StringValue(tenantId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTenantIdColumn() {
|
||||
return "tenant_id";
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean ignoreTable(String tableName) {
|
||||
// 忽略名单内的表不做租户隔离
|
||||
return ignoreTables.contains(tableName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
5.3 XML / 自定义 SQL 兼容性说明
|
||||
当前项目 XML 多为:
|
||||
resultMap;
|
||||
Base_Column_List 等列定义。
|
||||
这些 XML 并不影响多租户插件的生效,插件对最终 SQL 生效。
|
||||
需关注的场景:
|
||||
若存在大量手写复杂 SQL(join、多表统计等):
|
||||
插件会尝试对所有包含 tenant_id 字段的表自动加条件;
|
||||
对于需要跨租户查询的统计/后台管理接口,可通过:
|
||||
使用 MyBatis-Plus 提供的注解(如 @InterceptorIgnore(tenantLine = "true") 或对应版本配置)在 Mapper 方法上跳过租户拦截;
|
||||
或单独定义不走多租户的 Mapper/方法,仅用于运营后台。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
6. 数据写入与审计机制
|
||||
6.1 新增数据的 tenant_id 自动填充
|
||||
新增数据(INSERT)时需明确写入当前 tenant_id。可选方案:
|
||||
在各业务 Service 中,创建实体时手动设置:
|
||||
entity.setTenantId(TenantContextHolder.getTenantId());
|
||||
使用 MyBatis-Plus MetaObjectHandler:
|
||||
在 insertFill 中统一为 tenantId 字段填充当前租户,避免业务层遗漏。
|
||||
推荐方案:使用 MetaObjectHandler + 在特殊场景手动覆盖。
|
||||
|
||||
|
||||
|
||||
|
||||
6.2 更新数据的安全性
|
||||
一般不允许修改 tenant_id 字段;
|
||||
更新语句只在 WHERE 条件中带上 tenant_id = 当前租户,确保:
|
||||
不会更新到其他租户的数据;
|
||||
逻辑删除/批量更新等操作同样受租户约束。
|
||||
/**
|
||||
* MyBatis-Plus 公共字段自动填充处理器
|
||||
*
|
||||
* <p>
|
||||
* 仅负责在插入时为带有 tenantId 字段的实体自动填充当前租户ID,<br/>
|
||||
* 更新时不修改 tenantId,确保租户ID一旦写入不可被业务层随意修改。
|
||||
* </p>
|
||||
*/
|
||||
@Component
|
||||
public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
|
||||
|
||||
private static final String TENANT_FIELD = "tenantId";
|
||||
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
// 仅在实体中存在 tenantId 字段且当前值为空时进行填充
|
||||
if (metaObject.hasSetter(TENANT_FIELD)) {
|
||||
Object currentValue = getFieldValByName(TENANT_FIELD, metaObject);
|
||||
if (currentValue == null) {
|
||||
String tenantId = TenantContextHolder.getTenantId();
|
||||
if (tenantId != null) {
|
||||
this.strictInsertFill(metaObject, TENANT_FIELD, String.class, tenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
// 不自动更新 tenantId,保持租户ID稳定
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
6.3 审计字段补充(可选)
|
||||
在已有 create_time / update_time 基础上扩展:
|
||||
create_by / update_by(记录用户 ID);
|
||||
与租户联动后,便于定位“哪个租户、哪个用户、何时”操作了数据。
|
||||
|
||||
|
||||
|
||||
|
||||
7. 登录认证与租户绑定
|
||||
7.1 用户表改造
|
||||
在 user 表和对应实体中新增 tenant_id 字段;
|
||||
模型设计:
|
||||
简单模式:一个用户只属于一个租户(适合大多数 B 端场景);
|
||||
复杂模式:一个账号可归属多个租户,则需 user_tenant 关联表(如后期有此需求可扩展)。
|
||||
|
||||
|
||||
7.2 登录流程中的租户处理
|
||||
登录接口逻辑:
|
||||
用户名密码校验通过后,查询用户的 tenant_id;
|
||||
在返回的 token(JWT 或自定义)中写入 tenantId 声明;
|
||||
前端在发起业务请求时:
|
||||
将 token 放入 Authorization;
|
||||
将 tenantId 放入 X-Tenant-Id(可到期从 token 中解析填充)。
|
||||
服务端过滤器逻辑:
|
||||
从 token 与 Header 中解析并对比 tenantId,保持一致性;
|
||||
决定本次请求的租户上下文(设置 TenantContextHolder)。
|
||||
// TODO
|
||||
当前已经按你文档里的登录多租户方案,把后端改造完成:
|
||||
登录返回携带租户 ID:在 LoginResponse 中新增 tenantId 字段,并在 UserServiceImpl.login
|
||||
里从 user.getTenantId() 赋值,前端拿到后即可将其放入 X-Tenant-Id。
|
||||
通过 token 绑定租户:TenantFilter 现在会从 Authorization 头中解析 token(支持 Bearer xxx 或直接 token),
|
||||
调用 IUserService.getUserByToken 查出 User,得到 tokenTenantId。
|
||||
Header 与 token 的一致性与上下文设置:
|
||||
同时有 token 和 X-Tenant-Id 且不一致时会打出告警日志;
|
||||
优先使用 token 中的租户 ID,其次才用 X-Tenant-Id,然后调用
|
||||
TenantContextHolder.setTenantId(finalTenantId),在整个请求周期内为 MyBatis 多租户拦截器提供正确租户上下文。
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
8. 外部依赖与多租户适配(可选/后续)
|
||||
项目中已使用:
|
||||
Redis(会话、缓存、RAG 等)
|
||||
MinIO(音频/图片等文件存储)
|
||||
建议逐步考虑多租户隔离策略:
|
||||
8.1 Redis Key 命名规范
|
||||
为避免不同租户共享同一 Key 带来数据串扰:
|
||||
统一增加租户前缀:<tenantId>:<业务Key>;
|
||||
封装统一的 Redis 工具类,使调用侧只传业务 Key,由工具类自动添加租户前缀;
|
||||
对于确实需要跨租户的缓存(全局配置等),使用特殊前缀如 global:。
|
||||
8.2 MinIO 存储路径策略
|
||||
建议在对象路径或 bucket 中加入租户维度:
|
||||
对象前缀方式:tenant-<id>/audio/...;
|
||||
或 bucket 方式:audio-tenant-<id>;
|
||||
将路径构造封装在 MinIOUtil 或相关 service 中,避免业务代码直接拼接。
|
||||
9. 实施步骤(落地路线)
|
||||
9.1 设计与评审阶段
|
||||
明确业务侧租户定义(按公司/门店/项目等);
|
||||
确认需要隔离的表列表及需共享的公共表;
|
||||
设计 tenant 表结构以及 tenant_id 字段规范(类型、索引策略)。
|
||||
9.2 数据库改造
|
||||
编写 SQL 脚本:
|
||||
创建 tenant 表;
|
||||
为业务表增加 tenant_id 字段及索引;
|
||||
在测试环境执行、验证 SQL;
|
||||
插入默认租户,并为所有历史数据补齐 tenant_id。
|
||||
9.3 应用代码第一阶段改造
|
||||
为所有挂租户实体类添加 tenantId 字段及 @TableField("tenant_id");
|
||||
新增 TenantContextHolder;
|
||||
编写 TenantFilter/HandlerInterceptor,从请求头或 token 中解析租户,并设置/清理上下文;
|
||||
在 MybatisPlusConfig 中注册 TenantLineInnerInterceptor 与 TenantLineHandler,配置忽略表列表;
|
||||
(可选)实现 MetaObjectHandler 自动填充 tenantId。
|
||||
9.4 应用代码第二阶段改造与测试
|
||||
排查:
|
||||
是否有直接使用 JdbcTemplate 或原生 SQL 的地方,手动加 tenant_id 条件;
|
||||
需跨租户查询的运营接口,为 Mapper 方法配置忽略多租户拦截。
|
||||
改造登录模块:
|
||||
用户实体和数据库增加 tenant_id;
|
||||
登录成功后,将 tenantId 写入 token,并在前端请求中携带。
|
||||
编写联调/集成测试:
|
||||
准备两个租户数据,验证:
|
||||
A 租户的用户只看到 A 的数据;
|
||||
A、B 租户数据在分页、统计接口中互不干扰。
|
||||
9.5 外部服务与性能调优(可并行/后续)
|
||||
优化 Redis / MinIO 的多租户隔离(Key/路径规范);
|
||||
对热点业务表基于 tenant_id 索引进行慢 SQL 分析与优化;
|
||||
监控系统表现,如单库压力过高,后续可考虑按租户/时间进行分库分表。
|
||||
10. 总结
|
||||
本方案以 “租户字段 + 请求租户上下文 + MyBatis-Plus 多租户插件” 为核心,对现有项目做 最小侵入 的多租户改造;
|
||||
改造重点集中在:
|
||||
数据库新增 tenant_id 字段及索引;
|
||||
实体类字段同步;
|
||||
引入租户上下文、过滤器以及 MP 多租户插件;
|
||||
登录与用户表中引入租户绑定。
|
||||
在此基础上,后续可以平滑扩展:
|
||||
租户级配置中心;
|
||||
租户级限流、配额;
|
||||
租户级报表与运营管理。
|
||||
如果你需要,我可以基于本方案,进一步输出一份 具体到文件名/类名/方法名的“改造清单 + 示例代码片段”,方便你直接在 IDE 中按步骤实施。
|
||||
Reference in New Issue
Block a user