yh 1 месяц назад
Родитель
Сommit
1ad353f4a2

+ 118 - 0
fs-qwhook/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java

@@ -0,0 +1,118 @@
+package com.fs.framework.datasource;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.mapper.TenantInfoMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class TenantDataSourceManager {
+
+    private static final Logger log = LoggerFactory.getLogger(TenantDataSourceManager.class);
+
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+
+    @Resource
+    private TenantInfoMapper tenantInfoMapper;
+
+    /**
+     * 租户数据源缓存
+     */
+    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
+
+    /**
+     * 切换到租户数据源(不存在则创建)
+     */
+    public void switchTenant(TenantInfo tenantInfo) {
+        String tenantKey = buildTenantKey(tenantInfo.getId());
+
+        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+            synchronized (this) {
+                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+                    DataSource tenantDs = createTenantDataSource(tenantInfo);
+                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
+
+                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
+                    resolvedMap.put(tenantKey, tenantDs);
+                }
+            }
+        }
+
+        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+    }
+
+    private String buildTenantKey(Long tenantId) {
+        return "tenant:" + tenantId;
+    }
+
+    public void clear() {
+        DynamicDataSourceContextHolder.clearDataSourceType();
+    }
+
+    public void ensureSwitchByCorpId(String corpId) {
+        if (StringUtils.isBlank(corpId)) {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            return;
+        }
+
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        try {
+            TenantInfo tenantInfo = tenantInfoMapper.getTenByCorpId(corpId);
+            if (tenantInfo == null) {
+                log.warn("[TenantDS] corpId={} 未找到可用租户,回退到主库", corpId);
+                return;
+            }
+            if (!Integer.valueOf(1).equals(tenantInfo.getStatus())) {
+                log.warn("[TenantDS] corpId={} 对应租户已禁用,回退到主库", corpId);
+                return;
+            }
+            switchTenant(tenantInfo);
+            log.info("[TenantDS] 根据corpId切换租户数据源: corpId={}, tenantId={}, url={}", corpId, tenantInfo.getId(), tenantInfo.getDbUrl());
+        } catch (Exception e) {
+            log.error("[TenantDS] 根据corpId切换租户数据源失败, corpId={}, 回退到主库", corpId, e);
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        }
+    }
+
+    /**
+     * 创建租户数据源(MySQL + Druid)
+     */
+    private DataSource createTenantDataSource(TenantInfo tenant) {
+        DruidDataSource ds = new DruidDataSource();
+        ds.setUrl(tenant.getDbUrl());
+        ds.setUsername(tenant.getDbAccount());
+        ds.setPassword(tenant.getDbPwd());
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+        ds.setInitialSize(5);
+        ds.setMinIdle(10);
+        ds.setMaxActive(20);
+        ds.setMaxWait(60000);
+        return ds;
+    }
+
+    /**
+     * 反射获取 AbstractRoutingDataSource.resolvedDataSources
+     */
+    @SuppressWarnings("unchecked")
+    private Map<Object, DataSource> getResolvedDataSources() {
+        try {
+            Field field = org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
+                    .class.getDeclaredField("resolvedDataSources");
+            field.setAccessible(true);
+            return (Map<Object, DataSource>) field.get(dynamicDataSource);
+        } catch (Exception e) {
+            throw new IllegalStateException("获取 resolvedDataSources 失败", e);
+        }
+    }
+}

+ 121 - 0
fs-qwhook/src/main/java/com/fs/framework/filter/AppTenantSwitchFilter.java

@@ -0,0 +1,121 @@
+package com.fs.framework.filter;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.StringUtils;
+import com.fs.config.saas.ProjectConfig;
+import com.fs.core.config.TenantConfigContext;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
+import com.fs.framework.datasource.TenantDataSourceManager;
+import com.fs.framework.security.TenantPrincipal;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import com.fs.tenant.domain.TenantInfo;
+import com.fs.tenant.service.TenantInfoService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Collections;
+
+@Slf4j
+@Component
+@Order(5) // 在业务处理前尽早完成数据源和租户配置的上下文初始化
+public class AppTenantSwitchFilter extends OncePerRequestFilter {
+
+    /**
+     * 租户编码请求头名称。
+     * 前端需在每次请求中携带该头,才能启用多租户能力。
+     */
+    public static final String HEADER_TENANT_CODE = "X-Qw-CorpId";
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request,
+                                    HttpServletResponse response,
+                                    FilterChain filterChain) throws ServletException, IOException {
+        String corpId = request.getHeader(HEADER_TENANT_CODE);
+
+        try {
+            if (StringUtils.isBlank(corpId)) {
+                SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+                if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+                    TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()));
+                } else {
+                    TenantConfigContext.set(null);
+                }
+                ProjectConfig.loadTenantConfigsFromContext();
+
+                // 未携带企微编号:保持单库行为,直接放行
+                filterChain.doFilter(request, response);
+                return;
+            }
+
+            // Step 1:切到主库,根据 企微编号 查询租户信息
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+
+            TenantInfo tenantInfo = tenantInfoService.getTenByCorpId(corpId);
+            if (tenantInfo == null) {
+                log.warn("[SaaS qw-hook] 请求携带的 corpId={} 未找到租户记录,直接终止请求。", corpId);
+
+                // 方案1:返回401/403未授权(推荐,接口场景)
+                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
+
+                // 可选:返回JSON提示(前后端分离接口必加)
+                response.setContentType("application/json;charset=UTF-8");
+                response.getWriter().write("{\"code\":401,\"msg\":\"未找到对应租户,请求拒绝\"}");
+
+                return; // 直接退出,不执行任何后续逻辑
+            }
+
+            // Step 2:切换到租户库(如无则动态创建数据源)
+            tenantDataSourceManager.switchTenant(tenantInfo);
+
+            // Step 3:从租户库读取项目配置(projectConfig),填充 TenantConfigContext 和 ProjectConfig
+            SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+            if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+                TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()));
+            } else {
+                TenantConfigContext.set(null);
+            }
+            ProjectConfig.loadTenantConfigsFromContext();
+
+            // 设置租户到 SecurityContext,供 TenantKeyRedisSerializer 自动为 Redis Key 加 tenantid 前缀
+            SecurityContextHolder.getContext().setAuthentication(
+                    new UsernamePasswordAuthenticationToken(
+                            new TenantPrincipal(tenantInfo.getId()),
+                            null,
+                            Collections.emptyList()));
+
+            // Step 4:继续后续过滤器链和业务处理(此时 Mapper 操作的就是当前租户库)
+            filterChain.doFilter(request, response);
+        } finally {
+            // 确保请求结束后清理租户上下文和数据源标记,避免线程复用时串库
+            try {
+                ProjectConfig.clearTenantConfigs();
+            } catch (Exception ignored) {
+            }
+            TenantConfigContext.clear();
+            tenantDataSourceManager.clear();
+            SecurityContextHolder.clearContext();
+        }
+    }
+}
+

+ 17 - 0
fs-qwhook/src/main/java/com/fs/framework/security/TenantPrincipal.java

@@ -0,0 +1,17 @@
+package com.fs.framework.security;
+
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+/**
+ * 用于 SecurityContext 的租户身份,仅携带 tenantId。
+ * SecurityUtils.getTenantId() 会通过反射调用 getTenantId(),
+ * 使 TenantKeyRedisSerializer 能自动为 Redis Key 加上租户前缀。
+ */
+@Getter
+@AllArgsConstructor
+public class TenantPrincipal {
+
+    private final Long tenantId;
+}

+ 1 - 1
fs-service/src/main/java/com/fs/qw/mapper/QwExternalContactMapper.java

@@ -411,7 +411,7 @@ public interface QwExternalContactMapper extends BaseMapper<QwExternalContact> {
     void updateBindUserByQwUser(@Param("corpId")String corpId, @Param("qwUserId")String qwUserId, @Param("companyId")Long companyId, @Param("companyUserId")Long companyUserId);
 
     @Select("SELECT id,external_user_id,name,avatar,remark,description,fs_user_id FROM  qw_external_contact " +
-            " WHERE user_id = #{map.userId}   " +
+            " WHERE qw_open_user_id = #{map.userId}   " +
             "AND corp_id =#{map.corpId} " +
             "AND external_user_id = #{map.externalUserId} " +
             "AND `status` != 4 AND `status` != 5 " +

+ 1 - 1
fs-service/src/main/java/com/fs/qw/service/impl/QwJsApiServiceImpl.java

@@ -51,7 +51,7 @@ public class QwJsApiServiceImpl implements IQwJsApiService {
         try {
 
             QwCompany qwCompany = iQwCompanyService.selectQwCompanyByCorpId(qwConfigSignatureParam.getCorpId());
-            String appSecret = qwCompany.getOpenSecret();
+            String appSecret = qwCompany.getPermanentCode();
 
             QwJsapiTicketResult jsapiTicket = qwApiService.getJsapiTicket(qwConfigSignatureParam.getCorpId(),appSecret);
             QwJsapiTicketResult jsapiTicketApp = qwApiService.getJsapiTicketApp(qwConfigSignatureParam.getCorpId(),appSecret);