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 + * + *

包含:

+ * */ @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;