zyp 1 mēnesi atpakaļ
vecāks
revīzija
4a62dc4d6d

+ 152 - 0
docs/user-app更改.md

@@ -0,0 +1,152 @@
+[17 tools called]
+
+先修改 user-app 的数据源 key,然后新增租户数据源管理类和租户切换过滤器,最后编译+写文档。
+
+[22 tools called]
+
+下面是这次对 C 端 `fs-user-app` 的 SaaS 改造情况、用法和说明。
+
+---
+
+### 一、这次具体改了什么(代码层面)
+
+#### 1. 数据源 key 统一为字符串(MASTER/SLAVE)
+
+- **文件**:`com.fs.framework.config.DataSourceConfig`
+- **改动**:将原来用枚举作为 key 的写法统一为字符串 key:
+  ```java
+  targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
+  targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
+  targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
+  ```
+- **原因**:`DynamicDataSourceContextHolder.setDataSourceType()` 传的是字符串(如 `"MASTER"`),以前 `targetDataSources` 用的是 `DataSourceType.MASTER`(枚举),两者不相等,后面做动态切库会出问题,所以先把 key 统一成字符串。
+
+#### 2. 新增租户数据源管理类 `TenantDataSourceManager`
+
+- **文件**:`com.fs.framework.datasource.TenantDataSourceManager`
+- **作用**:
+    - 根据主库里的 `TenantInfo`(`tenant_info` 表)动态创建租户库的数据源(`DruidDataSource`);
+    - 把新数据源挂到当前 `DynamicDataSource` 的 `resolvedDataSources` 上;
+    - 调用 `DynamicDataSourceContextHolder.setDataSourceType("tenant:{tenantId}")` 把当前线程切到对应租户库;
+    - 提供 `clear()` 方法在请求结束时清理 ThreadLocal,避免线程复用串库。
+- **实现要点**:
+    - 内部用 `TENANT_DS_CACHE` 缓存租户数据源,避免重复创建连接池;
+    - 用反射访问 `AbstractRoutingDataSource.resolvedDataSources`,运行时追加数据源;
+    - 租户 key 统一为 `tenant:{id}`,和 admin/company 一致;
+    - 注释里已经详细解释了用途和注意点。
+
+#### 3. 新增租户切换过滤器 `AppTenantSwitchFilter`
+
+- **文件**:`com.fs.framework.filter.AppTenantSwitchFilter`
+- **注解**:`@Component`、`@Order(5)`,继承 `OncePerRequestFilter`。
+- **入口约定**:
+    - 使用请求头 **`X-Tenant-Code`** 作为 C 端的租户标识(和 admin 的 `tenantCode` 含义一致)。
+    - 前端 app / H5 / 小程序只要在请求头上带上这个租户编码,本次改造就会按该租户切库并加载租户配置。
+- **过滤逻辑(按请求)**:
+
+    1. 从请求头读取 `X-Tenant-Code`:
+        - 为空:**直接放行**,不做任何租户切换,兼容老逻辑(单库)。
+    2. 切到主库 MASTER:
+        - `DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());`
+    3. 用 `TenantInfoService` 在主库根据 `tenantCode` 查 `tenant_info`:
+        - 找不到 / 状态不为启用 / 已过期:记录日志,**放行但不切库**(保持单库行为)。
+    4. 对有效租户调用 `tenantDataSourceManager.switchTenant(tenant)` 切到该租户库:
+        - 如无数据源则创建一个 `DruidDataSource` 并挂到 `DynamicDataSource`;
+        - 当前线程的数据源 key 变为 `tenant:{id}`。
+    5. 在租户库中查询租户级项目配置:
+        - `SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");`
+        - 若有值:`TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()));`
+        - 然后调用 `ProjectConfig.loadTenantConfigsFromContext()`,让 Wx/支付等配置指向当前租户。
+    6. 调用 `filterChain.doFilter(request, response)`,后面的 Controller/Service/Mapper 就跑在该租户库上,并使用该租户的项目配置。
+    7. `finally` 中清理上下文:
+        - `ProjectConfig.clearTenantConfigs();`
+        - `TenantConfigContext.clear();`
+        - `tenantDataSourceManager.clear();`(内部清除数据源标记)。
+
+- **兼容性**:
+    - **不带 `X-Tenant-Code`**:一切照旧(单库)。
+    - **错误/禁用/过期租户**:仅打日志,不中断请求,退回单库行为(你可以后面再根据需要调整为直接返回错误)。
+
+---
+
+### 二、C 端前端需要怎么配合(非常关键)
+
+1. **每个 C 端请求都带上 `X-Tenant-Code`**:
+    - header 名:`X-Tenant-Code`;
+    - 值:你的租户编码(主库 `tenant_info.tenant_code`)。
+2. 包括以下类型的请求:
+    - 登录接口(`/app/app/login`、`/store/app/app/login` 等);
+    - 用户信息、课程、订单、SOP 等所有业务接口;
+    - 登出(如有)等。
+3. 租户编码获取方式(你可以按业务决定):
+    - 用户进入 app 之前选择医院/企业;
+    - 二级域名解析(如 `tenantA.xxx.com` → `tenantCode=tenantA`),前端启动时就把这个值带在 header 里。
+
+**只要 header 带得上,多租户这层就自动生效。**
+
+---
+
+### 三、现有登录 / Token / Redis 行为说明
+
+- user-app 目前的登录逻辑(`AppLoginController` / `AppLoginScrmController`)**没有改**:
+    - 还是用 `JwtUtils.generateToken(userId)` 生成 `APPToken`;
+    - 还是把 token 存到 Redis 的 `userToken:{userId}`;
+    - 鉴权还是用 header 里的 `APPToken` 解出 `userId`。
+- 这次的 SaaS 改造:
+    - **不改 token 结构**,不强行在 token 里塞 `tenantId`;
+    - **按请求头的 `X-Tenant-Code` 切库**,把“用户归属哪个租户”的责任交给前端来决定;
+    - Redis 这块暂时也不按租户做前缀,保证旧逻辑无感知(如果后面你要对 C 端 Redis 也做租户隔离,可以再单独一轮改造)。
+
+---
+
+### 四、如何自测 user-app C 端的多租户
+
+你可以用 Postman 或直接在 app 上做几组简单验证:
+
+1. **多租户切库验证**
+    - 在主库的 `tenant_info` 中配置两个租户 A / B,对应不同业务库,两个库中插入不同的测试数据;
+    - 调用同一个 C 端接口(比如课程列表),分别带:
+        - `X-Tenant-Code: A`
+        - `X-Tenant-Code: B`
+    - 确认两个请求看到的是各自库中的数据(比如医院名/课程名不同)。
+
+2. **租户配置隔离验证**
+    - 在租户 A/B 的各自库的 `sys_config.projectConfig` 里,配置不同的微信 / 支付参数(可以改一个明显的字段);
+    - 调用依赖这些配置的接口(如微信登录、支付相关接口),看是否按不同租户配置生效。
+
+3. **兼容性验证**
+    - 不带 `X-Tenant-Code` 调接口,确保行为与改造前一致(不报错,不异常,仍然能正常访问)。
+
+4. **异常租户验证**
+    - 用一个不存在的 `X-Tenant-Code` 请求,观察日志里有告警(租户不存在 / 禁用 / 过期),同时请求不会抛异常,只是退回用主库。
+
+---
+
+### 五、user-app 模块改造说明文档
+
+我已经帮你在项目里生成了一个专门的文档:
+
+- **路径**:`docs/user-app模块SaaS改造说明.md`
+
+里面详细写了:
+
+- 本次改造的目标、原则;
+- DataSourceConfig 的改动;
+- `TenantDataSourceManager` 和 `AppTenantSwitchFilter` 的设计与实现细节;
+- 前端如何带 `X-Tenant-Code`;
+- 推荐的自测场景。
+
+你可以直接把这个文档发给前端和其他后端同事,对接起来会更顺畅。
+
+---
+
+### 六、总结(给你一个操作要点版)
+
+- **后端**:
+    - 已经支持通过 `X-Tenant-Code` 切换到对应租户库,并加载该租户的 `projectConfig`。
+    - 老逻辑兼容,不带头还是单库。
+- **前端需要做的一件事**:
+    - 所有 user-app 请求(含登录)统一加上请求头:
+        - `X-Tenant-Code: <对应的租户编码>`。
+
+如果你希望下一步把 C 端的 Token 和 Redis 也做成像 admin/company 那种“token 里带 tenantId、Redis key 按租户隔离”的模式,我也可以再帮你设计一套不影响现有用户的渐进改法。

+ 156 - 0
docs/user-app模块SaaS改造说明.md

@@ -0,0 +1,156 @@
+# fs-user-app 模块 SaaS 改造说明(C 端用户端)
+
+本文档说明用户端模块 **fs-user-app** 在 SaaS 场景下的改造点:如何按租户切库、如何兼容旧版本以及前端需要配合的地方。
+
+---
+
+## 一、改造目标与原则
+
+1. **按租户切库**:同一套 user-app 服务实例支持多个租户,每个租户对应独立数据库(与 admin/company 一致)。  
+2. **按租户加载配置**:微信、支付等项目级配置继续通过 `sys_config.projectConfig` 在租户库中存储,并在请求链路中按租户加载。  
+3. **兼容旧版本**:未携带租户信息的请求,保持原有「单库」行为,避免一次性改动所有 C 端调用方。  
+4. **尽量不侵入业务**:现有 Controller 和 Service 尽量不改或少改,通过过滤器 + 动态数据源完成多租户能力。
+
+---
+
+## 二、整体设计
+
+### 2.1 租户识别方式(C 端)
+
+- 通过 **HTTP 请求头 `X-Tenant-Code`** 传递租户编码:  
+  - 该编码对应主库 `tenant_info.tenant_code` 字段;  
+  - 适用于 app、小程序、H5 等前端统一接入方式。
+- 若请求未携带 `X-Tenant-Code`:  
+  - 不做任何多租户切换,保持使用默认数据源(master + slave + sop),兼容单租户部署。
+
+### 2.2 请求链路(简化示意)
+
+```text
+Client(带 X-Tenant-Code) 
+    ↓
+AppTenantSwitchFilter(fs-user-app)
+    1. 切到主库 MASTER
+    2. 查 tenant_info(tenantCode)
+    3. TenantDataSourceManager.switchTenant(tenant) → 切库到租户库
+    4. 从租户库 sys_config.projectConfig 加载租户配置 → TenantConfigContext + ProjectConfig
+    ↓
+Controller / Service / Mapper
+    - Mapper 所在数据源 = 当前租户库
+    - 业务中读取租户配置(WxOpenProperties、ProjectConfig 等)= 当前租户配置
+```
+
+---
+
+## 三、具体代码改造
+
+### 3.1 DataSourceConfig:统一使用字符串 key
+
+**文件**:`fs-user-app/src/main/java/com/fs/framework/config/DataSourceConfig.java`
+
+- 原实现使用 `DataSourceType.MASTER`(枚举)作为 `targetDataSources` 的 key,而 `DynamicDataSourceContextHolder` 使用字符串(`DataSourceType.MASTER.name()`)作为 lookup key,两者不一致。
+- 改造后:
+
+```java
+// 使用字符串 key(MASTER/SLAVE),与 DynamicDataSourceContextHolder 中 setDataSourceType 保持一致
+targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
+targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
+targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
+```
+
+**效果**:  
+后续调用 `DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name())` 时,能正确匹配到 master 数据源。
+
+---
+
+### 3.2 新增 TenantDataSourceManager(租户数据源管理)
+
+**文件**:`fs-user-app/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java`
+
+核心职责:
+
+- 基于租户信息 `TenantInfo`(主库 `tenant_info` 表)动态创建 DruidDataSource;
+- 将新创建的数据源挂载到当前 `DynamicDataSource` 的 `resolvedDataSources` 中;
+- 调用 `DynamicDataSourceContextHolder.setDataSourceType("tenant:{id}")` 切到指定租户库;
+- 提供 `clear()` 方法清理当前线程的数据源标记。
+
+关键实现要点:
+
+- 缓存 map:`private static final Map<String, DataSource> TENANT_DS_CACHE`,避免重复创建连接池;
+- 通过反射访问 `AbstractRoutingDataSource.resolvedDataSources`,在运行时追加租户数据源;
+- 数据源 key 统一为 `tenant:{tenantId}`,与 admin/company 的约定一致。
+
+---
+
+### 3.3 新增 AppTenantSwitchFilter(按 X-Tenant-Code 切租户库)
+
+**文件**:`fs-user-app/src/main/java/com/fs/framework/filter/AppTenantSwitchFilter.java`
+
+类型:`OncePerRequestFilter`,`@Component`,`@Order(5)`。
+
+工作流程(每个 HTTP 请求):
+
+1. 从请求头读取 `X-Tenant-Code`:  
+   - 若为空:直接放行,不做多租户处理,兼容老逻辑。
+2. 切到主库(MASTER):
+   - `DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());`
+3. 通过 `TenantInfoService.selectTenantInfoList` 根据 `tenantCode` 查询租户信息:
+   - 若未查到:记录日志 + 放行(按单库行为);  
+   - 若 `status != 1` 或已过期:记录日志 + 放行。
+4. 对有效租户调用 `tenantDataSourceManager.switchTenant(tenant)`:  
+   - 如无对应数据源则创建,并将当前 ThreadLocal 标记为该租户数据源。
+5. 在租户库中读取项目配置:
+   - 调用 `sysConfigMapper.selectConfigByConfigKey("projectConfig")`;  
+   - 若有值,则 `TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()))`;  
+   - 然后调用 `ProjectConfig.loadTenantConfigsFromContext()`,使 Wx / 支付等配置指向当前租户。
+6. 调用 `filterChain.doFilter(request, response)` 继续后续业务处理。
+7. `finally` 中清理:
+   - `ProjectConfig.clearTenantConfigs();`  
+   - `TenantConfigContext.clear();`  
+   - `tenantDataSourceManager.clear();`(内部清理 `DynamicDataSourceContextHolder`)。
+
+**说明**:
+
+- 过滤器只负责租户与数据源上下文,不修改 C 端的登录 / 鉴权逻辑(`APPToken` 仍按原逻辑生成和校验)。  
+- 只要前端在所有请求(含登录)中带上 `X-Tenant-Code`,对应业务就会在该租户库上执行、并使用当前租户的项目配置。
+
+---
+
+## 四、兼容性与前端改造建议
+
+### 4.1 向下兼容
+
+- 未携带 `X-Tenant-Code` 的请求:  
+  - 不进入多租户分支,仅使用默认 master/slave/sop 数据源,与原逻辑完全一致。  
+  - 可用于单租户环境或过渡期。
+
+### 4.2 前端需配合的改造
+
+1. **在所有 user-app 请求中统一增加请求头 `X-Tenant-Code`**:  
+   - 值为当前访问医院/企业的租户编码(与 admin 端租户编码一致)。  
+   - 登录、业务接口、登出等全部接口建议统一加上。
+2. **租户编码的获取方式**:  
+   - 可通过登录前选择医院 / 租户,或通过域名映射(如二级域名解析出 tenantCode),再由前端写入请求头。
+
+---
+
+## 五、自测点(建议)
+
+1. **多租户切库验证**  
+   - 在主库 `tenant_info` 中配置两个租户 A/B,对应不同业务库。  
+   - 使用相同 user-app 代码,通过 Postman / App:  
+     - 分别带 `X-Tenant-Code: A` 和 `X-Tenant-Code: B` 调用同一接口(例如获取课程、订单列表),确认结果数据来自不同库。  
+2. **租户配置隔离验证**  
+   - 在两个租户库的 `sys_config.projectConfig` 中配置不同的微信/支付参数,观察调用相关功能时是否生效为各自租户的配置。  
+3. **无租户头兼容性**  
+   - 不带 `X-Tenant-Code` 调用常用接口,确认行为与改造前一致(不报错、不串库)。  
+4. **错误租户编码**  
+   - 带错误的 `X-Tenant-Code`(不存在 / 已禁用 / 已过期),确认日志中有告警,且请求退回单库行为(不异常中断)。  
+
+---
+
+## 六、与整体 SaaS 方案的关系
+
+- admin/company 模块已支持按租户切库并在 Token 中携带 `tenantId`;  
+- user-app 模块采用「<b>请求头传递租户编码</b> + <b>过滤器按租户切库</b>」的方式接入同一套多租户数据源与项目配置体系;  
+- 后续若需要在 C 端 Redis 中也按租户隔离 key,可在 user-app 的 Redis 配置中接入 `TenantKeyRedisSerializer`,并结合安全上下文或 Jwt 承载 `tenantId` 进行扩展(当前版本暂保留原有 Redis 行为,避免对线上 C 端产生影响)。\n
+

+ 3 - 2
fs-user-app/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -45,8 +45,9 @@ public class DataSourceConfig {
     @Primary
     public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("slaveDataSource") DataSource slaveDataSource, @Qualifier("sopDataSource") DataSource sopDataSource) {
         Map<Object, Object> targetDataSources = new HashMap<>();
-        targetDataSources.put(DataSourceType.MASTER, masterDataSource);
-        targetDataSources.put(DataSourceType.SLAVE, slaveDataSource);
+        // 使用字符串 key(MASTER/SLAVE),与 DynamicDataSourceContextHolder 中 setDataSourceType 保持一致
+        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
+        targetDataSources.put(DataSourceType.SLAVE.name(), slaveDataSource);
         targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
         return new DynamicDataSource(masterDataSource, targetDataSources);
     }

+ 121 - 0
fs-user-app/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java

@@ -0,0 +1,121 @@
+package com.fs.framework.datasource;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.tenant.domain.TenantInfo;
+
+import javax.annotation.Resource;
+import javax.sql.DataSource;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 租户数据源管理器(C 端 user-app 专用)。
+ *
+ * <p>职责:</p>
+ * <ul>
+ *     <li>基于租户信息 {@link TenantInfo} 动态创建 Druid 数据源。</li>
+ *     <li>将新创建的数据源注册到当前 {@link DynamicDataSource} 的 resolvedDataSources 中。</li>
+ *     <li>通过 {@link DynamicDataSourceContextHolder} 将当前线程切换到对应租户数据源。</li>
+ * </ul>
+ *
+ * <p>注意:</p>
+ * <ul>
+ *     <li>和 admin / company 模块保持一致,租户数据源的 key 约定为 <code>tenant:{tenantId}</code>。</li>
+ *     <li>本类只负责“注册 + 切换”数据源,不做业务校验(租户状态、过期时间等由调用方在主库中处理)。</li>
+ * </ul>
+ */
+public class TenantDataSourceManager {
+
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+
+    /**
+     * 已创建的租户数据源缓存,避免重复创建连接池。
+     * key 形如 tenant:1 / tenant:2 ...
+     */
+    private static final Map<String, DataSource> TENANT_DS_CACHE = new ConcurrentHashMap<>();
+
+    /**
+     * 切换到指定租户的数据源(如无则创建)。
+     *
+     * @param tenant 租户信息(需包含 id、dbUrl、dbAccount、dbPwd)
+     */
+    public void switchTenant(TenantInfo tenant) {
+        String tenantKey = buildTenantKey(tenant.getId());
+
+        // 双重检查锁:仅在缓存无对应数据源时创建,避免并发重复创建连接池
+        if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+            synchronized (this) {
+                if (!TENANT_DS_CACHE.containsKey(tenantKey)) {
+                    DataSource tenantDs = createTenantDataSource(tenant);
+                    TENANT_DS_CACHE.put(tenantKey, tenantDs);
+
+                    // 通过反射拿到 AbstractRoutingDataSource.resolvedDataSources 并追加租户数据源
+                    Map<Object, DataSource> resolvedMap = getResolvedDataSources();
+                    resolvedMap.put(tenantKey, tenantDs);
+                }
+            }
+        }
+
+        // 将当前线程的数据源标记为该租户
+        DynamicDataSourceContextHolder.setDataSourceType(tenantKey);
+    }
+
+    /**
+     * 构造租户数据源 key。
+     */
+    private String buildTenantKey(Long tenantId) {
+        return "tenant:" + tenantId;
+    }
+
+    /**
+     * 清理当前线程的数据源标记。
+     *
+     * <p>通常在一次 HTTP 请求结束或任务执行完成后调用,避免线程复用带来的串库问题。</p>
+     */
+    public void clear() {
+        DynamicDataSourceContextHolder.clearDataSourceType();
+    }
+
+    /**
+     * 基于租户信息创建 DruidDataSource。
+     *
+     * <p>此处统一使用 MySQL 驱动和一套通用连接池参数,若后续需要针对租户做更细粒度的池配置,
+     * 可在此方法中扩展。</p>
+     */
+    private DataSource createTenantDataSource(TenantInfo tenant) {
+        DruidDataSource ds = new DruidDataSource();
+        ds.setUrl(tenant.getDbUrl());
+        ds.setUsername(tenant.getDbAccount());
+        ds.setPassword(tenant.getDbPwd());
+
+        // 统一 MySQL 8 驱动
+        ds.setDriverClassName("com.mysql.cj.jdbc.Driver");
+
+        // 连接池基础配置(可根据实际情况调整)
+        ds.setInitialSize(5);
+        ds.setMinIdle(10);
+        ds.setMaxActive(20);
+        ds.setMaxWait(60000);
+
+        return ds;
+    }
+
+    /**
+     * 通过反射获取 {@link org.springframework.jdbc.datasource.lookup.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);
+        }
+    }
+}
+

+ 130 - 0
fs-user-app/src/main/java/com/fs/framework/filter/AppTenantSwitchFilter.java

@@ -0,0 +1,130 @@
+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.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.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.Date;
+import java.util.List;
+
+/**
+ * C 端 user-app 多租户过滤器。
+ *
+ * <p>职责:</p>
+ * <ul>
+ *     <li>从请求头 <b>X-Tenant-Code</b> 解析当前访问的租户编码(tenantCode)。</li>
+ *     <li>先切到主库(MASTER),根据 tenantCode 查询租户表 {@code tenant_info} 获取 {@link TenantInfo}。</li>
+ *     <li>调用 {@link TenantDataSourceManager#switchTenant(TenantInfo)} 将当前线程切换到对应租户库。</li>
+ *     <li>在租户库中读取 {@code sys_config.projectConfig},填充 {@link TenantConfigContext} 并调用 {@link ProjectConfig#loadTenantConfigsFromContext()}。</li>
+ * </ul>
+ *
+ * <p>注意:</p>
+ * <ul>
+ *     <li>若未携带 X-Tenant-Code,则保持原有逻辑(使用默认数据源,不做多租户切换),兼容老版本。</li>
+ *     <li>若 tenantCode 无效 / 被禁用 / 已过期,可根据需要返回错误码;当前实现仅记录日志并回退主库。</li>
+ *     <li>过滤器在请求结束时会清理 TenantConfigContext 与 DynamicDataSourceContextHolder,避免线程复用导致串库。</li>
+ * </ul>
+ */
+@Slf4j
+@Component
+@Order(5) // 在业务处理前尽早完成数据源和租户配置的上下文初始化
+public class AppTenantSwitchFilter extends OncePerRequestFilter {
+
+    /**
+     * 租户编码请求头名称。
+     * 前端需在每次请求中携带该头,才能启用多租户能力。
+     */
+    public static final String HEADER_TENANT_CODE = "X-Tenant-Code";
+
+    @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 tenantCode = request.getHeader(HEADER_TENANT_CODE);
+
+        try {
+            if (StringUtils.isBlank(tenantCode)) {
+                // 未携带租户编码:保持单库行为,直接放行
+                filterChain.doFilter(request, response);
+                return;
+            }
+
+            // Step 1:切到主库,根据 tenantCode 查询租户信息
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+
+            TenantInfo query = new TenantInfo();
+            query.setTenantCode(tenantCode);
+            List<TenantInfo> tenants = tenantInfoService.selectTenantInfoList(query);
+            if (tenants == null || tenants.isEmpty()) {
+                log.warn("[SaaS user-app] 请求携带的 tenantCode={} 未找到租户记录,按单库模式处理。", tenantCode);
+                filterChain.doFilter(request, response);
+                return;
+            }
+
+            TenantInfo tenant = tenants.get(0);
+
+            // 基础校验:启用状态 + 未过期
+            if (!Integer.valueOf(1).equals(tenant.getStatus())) {
+                log.warn("[SaaS user-app] 租户 tenantCode={} 已禁用,按单库模式处理。", tenantCode);
+                filterChain.doFilter(request, response);
+                return;
+            }
+            if (tenant.getExpireTime() != null && tenant.getExpireTime().before(new Date())) {
+                log.warn("[SaaS user-app] 租户 tenantCode={} 已过期,按单库模式处理。", tenantCode);
+                filterChain.doFilter(request, response);
+                return;
+            }
+
+            // Step 2:切换到租户库(如无则动态创建数据源)
+            tenantDataSourceManager.switchTenant(tenant);
+
+            // 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();
+
+            // Step 4:继续后续过滤器链和业务处理(此时 Mapper 操作的就是当前租户库)
+            filterChain.doFilter(request, response);
+        } finally {
+            // 确保请求结束后清理租户上下文和数据源标记,避免线程复用时串库
+            try {
+                ProjectConfig.clearTenantConfigs();
+            } catch (Exception ignored) {
+            }
+            TenantConfigContext.clear();
+            tenantDataSourceManager.clear();
+        }
+    }
+}
+