From 406f7d692baaeae4e35cd139b25ba24e22c92626 Mon Sep 17 00:00:00 2001
From: zhonghua1
Date: Tue, 16 Dec 2025 23:34:07 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A4=9A=E7=A7=9F=E6=88=B7=E4=BB=A3=E7=A0=81?=
=?UTF-8?q?=E6=A1=86=E6=9E=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...va => AISmartCard20251230Application.java} | 4 +-
.../java/com/rj/config/MybatisPlusConfig.java | 71 ++-
.../config/MybatisPlusMetaObjectHandler.java | 41 ++
src/main/java/com/rj/config/TenantFilter.java | 110 ++++
.../java/com/rj/entity/AudioManagement.java | 4 +
.../rj/entity/AudioManagementStatistics.java | 4 +
.../rj/entity/AudioTextAnalysisFurniture.java | 4 +
.../com/rj/entity/AudioTextAnalysisSop.java | 4 +
.../com/rj/entity/CommunicationRecord.java | 4 +
.../com/rj/entity/CustomerManagement.java | 4 +
.../rj/entity/CustomerProfileAnalysis.java | 6 +
src/main/java/com/rj/entity/Dealership.java | 4 +
.../java/com/rj/entity/FaceDetectLog.java | 6 +
.../java/com/rj/entity/ProjectManagement.java | 4 +
.../java/com/rj/entity/SalesManagement.java | 4 +
.../java/com/rj/entity/TtsRequestLog.java | 6 +
.../com/rj/entity/VideoImageAnalysis.java | 6 +
.../java/com/rj/entity/VideoManagement.java | 4 +
.../java/com/rj/entity/VideoSynthesisLog.java | 6 +
.../java/com/rj/entity/YhyAudioUploadLog.java | 4 +
src/main/java/com/rj/entity/sys/Role.java | 4 +
src/main/java/com/rj/entity/sys/User.java | 4 +
src/main/java/com/rj/entity/sys/UserRole.java | 4 +
.../java/com/rj/mapper/sys/UserMapper.java | 25 +-
.../java/com/rj/pojo/sys/LoginResponse.java | 3 +
.../rj/service/sys/impl/UserServiceImpl.java | 16 +-
.../com/rj/tenant/TenantContextHolder.java | 50 ++
src/main/sql/多租户技术方案.md | 573 ++++++++++++++++++
.../service/AudioManagementServiceTest.java | 2 -
.../CustomerManagementServiceTest.java | 2 -
.../com/rj/service/DealershipServiceTest.java | 2 -
.../service/DeviceManagementServiceTest.java | 2 -
.../service/ProjectManagementServiceTest.java | 2 -
.../rj/service/ReservationServiceTest.java | 2 -
.../service/SalesManagementServiceTest.java | 2 -
.../service/VideoManagementServiceTest.java | 2 -
36 files changed, 956 insertions(+), 39 deletions(-)
rename src/main/java/com/rj/{AISmartCard20250803Application.java => AISmartCard20251230Application.java} (94%)
create mode 100644 src/main/java/com/rj/config/MybatisPlusMetaObjectHandler.java
create mode 100644 src/main/java/com/rj/config/TenantFilter.java
create mode 100644 src/main/java/com/rj/tenant/TenantContextHolder.java
create mode 100644 src/main/sql/多租户技术方案.md
diff --git a/src/main/java/com/rj/AISmartCard20250803Application.java b/src/main/java/com/rj/AISmartCard20251230Application.java
similarity index 94%
rename from src/main/java/com/rj/AISmartCard20250803Application.java
rename to src/main/java/com/rj/AISmartCard20251230Application.java
index 8e9dae9..c65eee0 100644
--- a/src/main/java/com/rj/AISmartCard20250803Application.java
+++ b/src/main/java/com/rj/AISmartCard20251230Application.java
@@ -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);
}
}
diff --git a/src/main/java/com/rj/config/MybatisPlusConfig.java b/src/main/java/com/rj/config/MybatisPlusConfig.java
index 02ed62a..a08ef31 100644
--- a/src/main/java/com/rj/config/MybatisPlusConfig.java
+++ b/src/main/java/com/rj/config/MybatisPlusConfig.java
@@ -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
+ *
+ * 包含:
+ *
+ * - 多租户拦截器:根据当前线程中的租户ID自动为SQL拼接 tenant_id 条件;
+ * - 分页插件;
+ * - 乐观锁插件。
+ *
*/
@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;
}
-}
\ No newline at end of file
+
+ /**
+ * 多租户处理器:定义如何获取租户ID、租户字段名以及忽略的表
+ */
+ @Bean
+ public TenantLineHandler tenantLineHandler() {
+ // 需要忽略多租户的表(不自动拼接 tenant_id)
+ Set 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);
+ }
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/rj/config/MybatisPlusMetaObjectHandler.java b/src/main/java/com/rj/config/MybatisPlusMetaObjectHandler.java
new file mode 100644
index 0000000..e9c3313
--- /dev/null
+++ b/src/main/java/com/rj/config/MybatisPlusMetaObjectHandler.java
@@ -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 公共字段自动填充处理器
+ *
+ *
+ * 仅负责在插入时为带有 tenantId 字段的实体自动填充当前租户ID,
+ * 更新时不修改 tenantId,确保租户ID一旦写入不可被业务层随意修改。
+ *
+ */
+@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稳定
+ }
+}
+
+
diff --git a/src/main/java/com/rj/config/TenantFilter.java b/src/main/java/com/rj/config/TenantFilter.java
new file mode 100644
index 0000000..b6fa5d9
--- /dev/null
+++ b/src/main/java/com/rj/config/TenantFilter.java
@@ -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;
+
+/**
+ * 多租户过滤器:
+ *
+ * - 从 HTTP Header 中读取租户ID:X-Tenant-Id
+ * - 将租户ID写入 {@link TenantContextHolder}
+ * - 在请求结束时清理线程变量,避免线程复用造成的串租户问题
+ *
+ */
+@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;
+ }
+}
+
+
+
diff --git a/src/main/java/com/rj/entity/AudioManagement.java b/src/main/java/com/rj/entity/AudioManagement.java
index 00da749..bc62451 100644
--- a/src/main/java/com/rj/entity/AudioManagement.java
+++ b/src/main/java/com/rj/entity/AudioManagement.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/AudioManagementStatistics.java b/src/main/java/com/rj/entity/AudioManagementStatistics.java
index aa7211d..1b3430a 100644
--- a/src/main/java/com/rj/entity/AudioManagementStatistics.java
+++ b/src/main/java/com/rj/entity/AudioManagementStatistics.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/AudioTextAnalysisFurniture.java b/src/main/java/com/rj/entity/AudioTextAnalysisFurniture.java
index df9ebdd..b030bc3 100644
--- a/src/main/java/com/rj/entity/AudioTextAnalysisFurniture.java
+++ b/src/main/java/com/rj/entity/AudioTextAnalysisFurniture.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/AudioTextAnalysisSop.java b/src/main/java/com/rj/entity/AudioTextAnalysisSop.java
index ab5f169..d89ee5d 100644
--- a/src/main/java/com/rj/entity/AudioTextAnalysisSop.java
+++ b/src/main/java/com/rj/entity/AudioTextAnalysisSop.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/CommunicationRecord.java b/src/main/java/com/rj/entity/CommunicationRecord.java
index eaa871c..bcdad46 100644
--- a/src/main/java/com/rj/entity/CommunicationRecord.java
+++ b/src/main/java/com/rj/entity/CommunicationRecord.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/CustomerManagement.java b/src/main/java/com/rj/entity/CustomerManagement.java
index d187539..e05779c 100644
--- a/src/main/java/com/rj/entity/CustomerManagement.java
+++ b/src/main/java/com/rj/entity/CustomerManagement.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/CustomerProfileAnalysis.java b/src/main/java/com/rj/entity/CustomerProfileAnalysis.java
index 1aae990..a715272 100644
--- a/src/main/java/com/rj/entity/CustomerProfileAnalysis.java
+++ b/src/main/java/com/rj/entity/CustomerProfileAnalysis.java
@@ -21,6 +21,12 @@ public class CustomerProfileAnalysis {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
+ /**
+ * 租户ID
+ */
+ @TableField("tenant_id")
+ private String tenantId;
+
/**
* AI分析记录ID
*/
diff --git a/src/main/java/com/rj/entity/Dealership.java b/src/main/java/com/rj/entity/Dealership.java
index 72e1f32..8e3249b 100644
--- a/src/main/java/com/rj/entity/Dealership.java
+++ b/src/main/java/com/rj/entity/Dealership.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/FaceDetectLog.java b/src/main/java/com/rj/entity/FaceDetectLog.java
index 9977e78..f8b7eac 100644
--- a/src/main/java/com/rj/entity/FaceDetectLog.java
+++ b/src/main/java/com/rj/entity/FaceDetectLog.java
@@ -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
*/
diff --git a/src/main/java/com/rj/entity/ProjectManagement.java b/src/main/java/com/rj/entity/ProjectManagement.java
index ad30c3e..269063f 100644
--- a/src/main/java/com/rj/entity/ProjectManagement.java
+++ b/src/main/java/com/rj/entity/ProjectManagement.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/SalesManagement.java b/src/main/java/com/rj/entity/SalesManagement.java
index e769d6d..9cbfeb5 100644
--- a/src/main/java/com/rj/entity/SalesManagement.java
+++ b/src/main/java/com/rj/entity/SalesManagement.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/TtsRequestLog.java b/src/main/java/com/rj/entity/TtsRequestLog.java
index dd13fa4..7a343e9 100644
--- a/src/main/java/com/rj/entity/TtsRequestLog.java
+++ b/src/main/java/com/rj/entity/TtsRequestLog.java
@@ -23,6 +23,12 @@ public class TtsRequestLog {
*/
@TableId(type = IdType.ASSIGN_UUID)
private String id;
+
+ /**
+ * 租户ID
+ */
+ @TableField("tenant_id")
+ private String tenantId;
/**
* 请求时间
diff --git a/src/main/java/com/rj/entity/VideoImageAnalysis.java b/src/main/java/com/rj/entity/VideoImageAnalysis.java
index a2de68d..f02c39f 100644
--- a/src/main/java/com/rj/entity/VideoImageAnalysis.java
+++ b/src/main/java/com/rj/entity/VideoImageAnalysis.java
@@ -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模型
*/
diff --git a/src/main/java/com/rj/entity/VideoManagement.java b/src/main/java/com/rj/entity/VideoManagement.java
index 3e9c2ed..b177b0f 100644
--- a/src/main/java/com/rj/entity/VideoManagement.java
+++ b/src/main/java/com/rj/entity/VideoManagement.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/VideoSynthesisLog.java b/src/main/java/com/rj/entity/VideoSynthesisLog.java
index 03aa44f..06d299e 100644
--- a/src/main/java/com/rj/entity/VideoSynthesisLog.java
+++ b/src/main/java/com/rj/entity/VideoSynthesisLog.java
@@ -27,6 +27,12 @@ public class VideoSynthesisLog {
@TableId(type = IdType.ASSIGN_UUID)
private String id;
+ /**
+ * 租户ID
+ */
+ @TableField("tenant_id")
+ private String tenantId;
+
/**
* 请求ID(唯一)
*/
diff --git a/src/main/java/com/rj/entity/YhyAudioUploadLog.java b/src/main/java/com/rj/entity/YhyAudioUploadLog.java
index bd94857..315aacd 100644
--- a/src/main/java/com/rj/entity/YhyAudioUploadLog.java
+++ b/src/main/java/com/rj/entity/YhyAudioUploadLog.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/sys/Role.java b/src/main/java/com/rj/entity/sys/Role.java
index 49234e0..9ac1d68 100644
--- a/src/main/java/com/rj/entity/sys/Role.java
+++ b/src/main/java/com/rj/entity/sys/Role.java
@@ -33,4 +33,8 @@ public class Role implements Serializable {
@TableField("role_name")
private String roleName;
+ @Schema(description = "租户ID")
+ @TableField("tenant_id")
+ private String tenantId;
+
}
diff --git a/src/main/java/com/rj/entity/sys/User.java b/src/main/java/com/rj/entity/sys/User.java
index ba60735..692dcdc 100644
--- a/src/main/java/com/rj/entity/sys/User.java
+++ b/src/main/java/com/rj/entity/sys/User.java
@@ -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;
diff --git a/src/main/java/com/rj/entity/sys/UserRole.java b/src/main/java/com/rj/entity/sys/UserRole.java
index 726a0b2..0ecfa1f 100644
--- a/src/main/java/com/rj/entity/sys/UserRole.java
+++ b/src/main/java/com/rj/entity/sys/UserRole.java
@@ -33,4 +33,8 @@ public class UserRole implements Serializable {
@TableField("role_id")
private Integer roleId;
+ @Schema(description = "租户ID")
+ @TableField("tenant_id")
+ private String tenantId;
+
}
diff --git a/src/main/java/com/rj/mapper/sys/UserMapper.java b/src/main/java/com/rj/mapper/sys/UserMapper.java
index 26f7ea9..935a633 100644
--- a/src/main/java/com/rj/mapper/sys/UserMapper.java
+++ b/src/main/java/com/rj/mapper/sys/UserMapper.java
@@ -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;
/**
*
* 用户表 Mapper 接口
*
*
+ *
+ * 说明:
+ * - 登录认证/根据 token 查询用户时,需要跨租户查询用户信息,因此这些方法通过
+ * {@link InterceptorIgnore} 显式跳过多租户拦截器,不在 SQL 中追加 tenant_id 条件。
+ * - 其他通过 MyBatis-Plus 内置 CRUD 产生的 SQL 仍然会自动带上 tenant_id 条件。
+ *
+ *
* @author 系统生成
* @since 2025-08-07
*/
public interface UserMapper extends BaseMapper {
+ /**
+ * 根据用户名查询用户(登录用),不走多租户拦截器。
+ */
+ @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);
}
diff --git a/src/main/java/com/rj/pojo/sys/LoginResponse.java b/src/main/java/com/rj/pojo/sys/LoginResponse.java
index 3cbe5bb..af922b3 100644
--- a/src/main/java/com/rj/pojo/sys/LoginResponse.java
+++ b/src/main/java/com/rj/pojo/sys/LoginResponse.java
@@ -34,6 +34,9 @@ public class LoginResponse {
@Schema(description = "登录时间")
private String loginTime;
+ @Schema(description = "租户ID")
+ private String tenantId;
+
}
diff --git a/src/main/java/com/rj/service/sys/impl/UserServiceImpl.java b/src/main/java/com/rj/service/sys/impl/UserServiceImpl.java
index 36e2de2..aa08ab1 100644
--- a/src/main/java/com/rj/service/sys/impl/UserServiceImpl.java
+++ b/src/main/java/com/rj/service/sys/impl/UserServiceImpl.java
@@ -27,8 +27,8 @@ public class UserServiceImpl extends ServiceImpl 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 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 queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName, userName);
return this.getOne(queryWrapper);
@@ -68,16 +70,14 @@ public class UserServiceImpl extends ServiceImpl implements IU
@Override
public User getUserByToken(String token) {
- LambdaQueryWrapper 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 queryWrapper = new LambdaQueryWrapper<>();
- queryWrapper.eq(User::getToken, token);
- return this.getOne(queryWrapper);
+ // 获取用户详细信息,同样使用跨租户查询
+ return this.baseMapper.selectByTokenIgnoreTenant(token);
}
}
diff --git a/src/main/java/com/rj/tenant/TenantContextHolder.java b/src/main/java/com/rj/tenant/TenantContextHolder.java
new file mode 100644
index 0000000..44cd9e3
--- /dev/null
+++ b/src/main/java/com/rj/tenant/TenantContextHolder.java
@@ -0,0 +1,50 @@
+package com.rj.tenant;
+
+/**
+ * 多租户上下文持有器,用于在当前线程中保存和获取租户ID。
+ *
+ *
+ * 典型使用场景:
+ *
+ * - 在请求入口(过滤器 / 拦截器)中根据 Header / Token 解析出租户ID并设置;
+ * - MyBatis-Plus 的 TenantLineHandler 从此处读取当前租户;
+ * - 业务代码中偶尔需要获取当前请求所在的租户。
+ *
+ * 使用完成后务必调用 {@link #clear()} 清理,避免线程复用导致租户串用。
+ *
+ */
+public final class TenantContextHolder {
+
+ private static final ThreadLocal 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();
+ }
+}
+
+
diff --git a/src/main/sql/多租户技术方案.md b/src/main/sql/多租户技术方案.md
new file mode 100644
index 0000000..ae29027
--- /dev/null
+++ b/src/main/sql/多租户技术方案.md
@@ -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 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 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 公共字段自动填充处理器
+ *
+ *
+ * 仅负责在插入时为带有 tenantId 字段的实体自动填充当前租户ID,
+ * 更新时不修改 tenantId,确保租户ID一旦写入不可被业务层随意修改。
+ *
+ */
+@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 带来数据串扰:
+统一增加租户前缀::<业务Key>;
+封装统一的 Redis 工具类,使调用侧只传业务 Key,由工具类自动添加租户前缀;
+对于确实需要跨租户的缓存(全局配置等),使用特殊前缀如 global:。
+8.2 MinIO 存储路径策略
+建议在对象路径或 bucket 中加入租户维度:
+对象前缀方式:tenant-/audio/...;
+或 bucket 方式:audio-tenant-;
+将路径构造封装在 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 中按步骤实施。
\ No newline at end of file
diff --git a/src/test/java/com/rj/service/AudioManagementServiceTest.java b/src/test/java/com/rj/service/AudioManagementServiceTest.java
index ce4c198..ca7352f 100644
--- a/src/test/java/com/rj/service/AudioManagementServiceTest.java
+++ b/src/test/java/com/rj/service/AudioManagementServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.AudioManagement;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDateTime;
diff --git a/src/test/java/com/rj/service/CustomerManagementServiceTest.java b/src/test/java/com/rj/service/CustomerManagementServiceTest.java
index daee92e..e47a95f 100644
--- a/src/test/java/com/rj/service/CustomerManagementServiceTest.java
+++ b/src/test/java/com/rj/service/CustomerManagementServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.CustomerManagement;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.List;
diff --git a/src/test/java/com/rj/service/DealershipServiceTest.java b/src/test/java/com/rj/service/DealershipServiceTest.java
index 9e4748b..7cd5bd1 100644
--- a/src/test/java/com/rj/service/DealershipServiceTest.java
+++ b/src/test/java/com/rj/service/DealershipServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.Dealership;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDateTime;
diff --git a/src/test/java/com/rj/service/DeviceManagementServiceTest.java b/src/test/java/com/rj/service/DeviceManagementServiceTest.java
index 011846a..c014268 100644
--- a/src/test/java/com/rj/service/DeviceManagementServiceTest.java
+++ b/src/test/java/com/rj/service/DeviceManagementServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.DeviceManagement;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDateTime;
import java.util.List;
diff --git a/src/test/java/com/rj/service/ProjectManagementServiceTest.java b/src/test/java/com/rj/service/ProjectManagementServiceTest.java
index 7076ae2..2383f05 100644
--- a/src/test/java/com/rj/service/ProjectManagementServiceTest.java
+++ b/src/test/java/com/rj/service/ProjectManagementServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.ProjectManagement;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDateTime;
diff --git a/src/test/java/com/rj/service/ReservationServiceTest.java b/src/test/java/com/rj/service/ReservationServiceTest.java
index d569172..58bebdd 100644
--- a/src/test/java/com/rj/service/ReservationServiceTest.java
+++ b/src/test/java/com/rj/service/ReservationServiceTest.java
@@ -6,14 +6,12 @@ package com.rj.service;
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.pojo.Reservation;
import com.rj.service.impl.ReservationServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.*;
diff --git a/src/test/java/com/rj/service/SalesManagementServiceTest.java b/src/test/java/com/rj/service/SalesManagementServiceTest.java
index 66a16a2..c874ab9 100644
--- a/src/test/java/com/rj/service/SalesManagementServiceTest.java
+++ b/src/test/java/com/rj/service/SalesManagementServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.SalesManagement;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDateTime;
diff --git a/src/test/java/com/rj/service/VideoManagementServiceTest.java b/src/test/java/com/rj/service/VideoManagementServiceTest.java
index 896a9a8..3fa2870 100644
--- a/src/test/java/com/rj/service/VideoManagementServiceTest.java
+++ b/src/test/java/com/rj/service/VideoManagementServiceTest.java
@@ -5,11 +5,9 @@ package com.rj.service;
* Date: 2025/8/5 19:02
**/
-import com.rj.AISmartCard20250803Application;
import com.rj.entity.VideoManagement;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDateTime;