zyp 3 месяцев назад
Родитель
Сommit
d788bb7f18

+ 6 - 0
.vscode/settings.json

@@ -0,0 +1,6 @@
+{
+  // 使用当前用户的 Maven settings.xml(其中已配置 localRepository = D:\\Tool\\repository)
+  "java.configuration.maven.userSettings": "C:\\Users\\Administrator\\.m2\\settings.xml",
+  // Maven 可执行文件路径,便于 IDE 与终端使用
+  "maven.executable.path": "D:\\Tool\\apache-maven-3.6.3\\bin\\mvn.cmd"
+}

+ 314 - 0
docs/SaaS改造方案.md

@@ -0,0 +1,314 @@
+# 互联网医院平台 SaaS 化改造方案
+
+## 一、现状总结
+
+### 1.1 已有能力(可直接复用)
+
+| 能力 | 位置 | 说明 |
+|------|------|------|
+| 租户表与 CRUD | `fs-service` / `tenant_info` | `TenantInfo` 含 dbUrl/dbAccount/dbPwd,支持按库隔离 |
+| 动态租户数据源 | `fs-framework` / `TenantDataSourceManager` | 按 tenantId 动态创建并缓存 Druid 数据源,注入 `DynamicDataSource` |
+| 管理端多租户登录 | `fs-admin` / `SysLoginController` + `SysLoginService` | 登录支持 `LoginBody.tenantCode`,校验后切租户库并设置 `LoginUser.tenantId` |
+| 请求级切库 + 租户配置 | `fs-framework` / `JwtAuthenticationTokenFilter` | 从 Token 取 `tenantId` → 设置数据源 + 从当前库查 `projectConfig` 写入 `TenantConfigContext` |
+| 租户配置存储 | 各租户库 `sys_config`(config_key=projectConfig) | 微信、支付、云存储等按租户隔离 |
+| Redis 租户前缀 | `fs-common` / `TenantKeyRedisSerializer` | Key 前缀 `tenantid:{tenantId}`,需确保取 tenantId 的上下文一致 |
+
+### 1.2 当前缺口(需改造)
+
+- **仅管理端走完整租户链路**:`fs-admin`、部分 `fs-company` 使用 framework 的 `JwtAuthenticationTokenFilter` + `DynamicDataSource`;**fs-user-app、fs-doctor-app、fs-store 等 C 端/应用端未接入**。
+- **C 端认证独立**:user-app 使用自有 `JwtUtils` + 请求头 `APPToken`,不经过 framework,**无 tenantId、无切库、无 TenantConfigContext**。
+- **租户识别方式未统一**:C 端用户如何归属到某租户(域名、请求头、登录时选租户等)未在代码中体现。
+- **数据隔离方式**:当前为「每租户独立库」,业务表无 `tenant_id` 字段;若未来要支持「共享库 + tenant_id」,需单独规划。
+
+---
+
+## 二、改造目标
+
+1. **全模块 SaaS 化**:所有对外入口(admin、user-app、doctor-app、company-app、store、各 API 等)请求链路均带租户身份,按租户切库或使用租户配置。
+2. **租户识别统一**:管理端继续用「登录时 tenantCode」;C 端明确一种或多种方式(如请求头 `X-Tenant-Code`、二级域名、或登录接口选租户)。
+3. **Token 与上下文统一**:凡需多租户的应用,Token 或请求上下文中携带 `tenantId`,并统一用 framework 的过滤器做切库与 `TenantConfigContext` 注入。
+4. **配置与 Redis**:租户级配置继续用 `sys_config` + `TenantConfigContext`;Redis 使用处统一带租户前缀(`TenantKeyRedisSerializer` 依赖的 tenantId 需在请求链中可用)。
+
+---
+
+## 三、整体架构(改造后)
+
+```
+                    ┌─────────────────────────────────────────────────────────┐
+                    │                    统一租户识别入口                        │
+                    │  (域名 / X-Tenant-Code / 登录参数 → tenantId)             │
+                    └─────────────────────────────────────────────────────────┘
+                                              │
+         ┌────────────────────────────────────┼────────────────────────────────────┐
+         ▼                                    ▼                                    ▼
+   fs-admin (已有)                    fs-user-app (需改)                    fs-doctor-app 等
+   - 登录带 tenantCode                - 接入 framework 过滤器               - 同 user-app
+   - JWT 含 tenantId                  - Token 含 tenantId                    - Token 含 tenantId
+   - 请求切库 + TenantConfigContext   - 请求切库 + TenantConfigContext       - 请求切库 + TenantConfigContext
+         │                                    │                                    │
+         └────────────────────────────────────┼────────────────────────────────────┘
+                                              ▼
+                    ┌─────────────────────────────────────────────────────────┐
+                    │  fs-framework:JwtAuthenticationTokenFilter             │
+                    │  - 解析 Token → LoginUser(tenantId)                      │
+                    │  - DynamicDataSourceContextHolder.set("tenant:"+tenantId)  │
+                    │  - TenantConfigContext.set(projectConfig)                │
+                    │  - ProjectConfig.loadTenantConfigsFromContext()          │
+                    └─────────────────────────────────────────────────────────┘
+                                              │
+                    ┌─────────────────────────┼─────────────────────────┐
+                    ▼                         ▼                         ▼
+              主库 (tenant_info 等)    租户库 A (业务数据)        租户库 B (业务数据)
+```
+
+---
+
+## 四、主要改动点及改法
+
+### 4.1 租户识别策略(先定方案再落地)
+
+| 入口 | 推荐方式 | 说明 |
+|------|----------|------|
+| 管理端 fs-admin | 登录参数 `tenantCode`(已有) | 保持不变 |
+| C 端 fs-user-app / H5/小程序 | **方案 A**:登录接口增加 `tenantCode`,与账号一起校验后写进 Token;**方案 B**:请求头 `X-Tenant-Code`,网关/过滤器先解析再查主库得到 tenantId | 二选一或组合(如 B 做兜底) |
+| 企业端 fs-company-app | 同 C 端或与管理端一致 | 视是否同套登录 |
+| 各 API(fs-wx-api、fs-store 等) | 请求头或 Token 中的 tenantId/tenantCode | 与网关/过滤器约定 |
+
+**落地要点**:
+
+- 在 **fs-common** 或 **fs-framework** 中新增「租户解析」工具:根据请求头 `X-Tenant-Code` 或参数从主库查 `tenant_info` 得到 `tenantId`,并放入 ThreadLocal 或请求属性,供过滤器和业务使用。
+- 若 C 端登录选租户:在 **fs-user-app** 的登录接口增加 `tenantCode`,校验通过后调用与 admin 类似的「查租户 → switchTenant → 生成 Token 并设置 LoginUser.tenantId」逻辑(可抽到 framework 的 `SysLoginService` 或新服务中复用)。
+
+---
+
+### 4.2 fs-user-app(C 端)接入多租户
+
+**目标**:C 端请求也按租户切库并使用 `TenantConfigContext`,与 admin 行为一致。
+
+#### 4.2.1 依赖与数据源
+
+- **pom.xml**:增加对 **fs-framework** 的依赖(若尚未引入),以便使用 `JwtAuthenticationTokenFilter`、`DynamicDataSource`、`TenantDataSourceManager`、`TenantConfigContext`。
+- **数据源**:改为使用 **fs-framework** 的 `DataSourceConfig`(即主库 + 可选 SOP/从库 + 租户动态数据源),或在本模块复制一份并保证 `DynamicDataSource` 注入 `TenantDataSourceManager` 使用的同一实例,这样 `TenantDataSourceManager` 动态加入的租户库才会生效。
+
+#### 4.2.2 认证与 Token 统一
+
+- **Token 生成**:C 端登录(微信/手机号/账号密码等)时,若需多租户,则:
+  - 入参增加 `tenantCode`(或从请求头 `X-Tenant-Code` 取);
+  - 查主库 `tenant_info` 校验租户状态;
+  - 调用 `TenantDataSourceManager.switchTenant(tenantInfo)` 切换到该租户库(便于本次登录逻辑里查该租户下的用户等);
+  - 构建 `LoginUser` 并 **setTenantId(tenantInfo.getId())**;
+  - 使用 **fs-framework** 的 `TokenService.createToken(loginUser)` 生成 JWT,这样 JWT 中会带 tenantId。
+- **请求头**:为与现有前端兼容,可保留 `APPToken` 作为 Token 传递方式;在 **fs-framework** 的 `TokenService.getLoginUser(request)` 中支持从 `APPToken` 解析(或 user-app 单独一个 Filter 先取 APPToken 再塞到 `Authorization`),保证后续 Filter 拿到的 `LoginUser` 含 tenantId。
+
+#### 4.2.3 启用 framework 的 JWT + 租户过滤器
+
+- **SecurityConfig**:在 **fs-user-app** 的 `SecurityConfig` 中,加入与 fs-admin 相同的 **JwtAuthenticationTokenFilter**(并注入 `TokenService`、`DynamicDataSource`、`SysConfigMapper`),保证每个请求:
+  - 解析 Token 得到 `LoginUser`(含 tenantId);
+  - 按 tenantId 设置 `DynamicDataSourceContextHolder` 和 `TenantConfigContext`;
+  - 在 finally 中清理上下文。
+- 若 user-app 当前用的是 **Interceptor**(如 `AuthorizationInterceptor`)+ 自有 `JwtUtils`,可逐步替换:先让 Filter 只做「解析 Token → 设置租户上下文」,业务仍用现有 getUserId();再逐步把 getUserId/getTenantId 统一为从 `SecurityUtils.getLoginUser()` 取。
+
+#### 4.2.4 基类与业务使用租户上下文
+
+- **AppBaseController**:增加 `getTenantId()`,从 `SecurityUtils.getLoginUser().getTenantId()` 或从当前请求的租户 ThreadLocal 取;现有 `getUserId()` 可改为优先从 `LoginUser` 取,便于与 framework 统一。
+- 所有需要「当前租户」的 Service/Mapper,在请求链中不再传 tenantId 参数,而是统一从 **TenantConfigContext** 或 **DynamicDataSourceContextHolder** 间接使用(当前库即租户库);若业务要写 Redis,确保使用的 RedisTemplate 已配置 **TenantKeyRedisSerializer**,且请求链中 tenantId 已设置。
+
+#### 4.2.5 小结:fs-user-app 改动清单
+
+| 序号 | 改动项 | 说明 |
+|------|--------|------|
+| 1 | 引入 fs-framework 依赖 | 使用其 DataSourceConfig、JwtAuthenticationTokenFilter、TokenService、TenantDataSourceManager |
+| 2 | 数据源改为 DynamicDataSource + 租户 | 与 framework 一致,保证租户库被动态注入 |
+| 3 | C 端登录入参/请求头带 tenantCode | 登录时解析租户并 setTenantId,Token 用 TokenService 生成 |
+| 4 | Security 中注册 JwtAuthenticationTokenFilter | 顺序在认证之前,确保每次请求设好数据源与 TenantConfigContext |
+| 5 | TokenService 支持从 APPToken 取 Token | 或 Filter 内兼容 APPToken 请求头 |
+| 6 | AppBaseController 提供 getTenantId() | 业务按需使用 |
+| 7 | Redis 使用 TenantKeyRedisSerializer | 若 user-app 有独立 RedisConfig,需与 common 一致,并保证 tenantId 在上下文可用 |
+
+---
+
+### 4.3 fs-doctor-app / fs-company-app / fs-store 等
+
+- 思路与 **fs-user-app** 相同:**依赖 framework → 数据源统一为 DynamicDataSource + 租户 → 登录/请求带 tenantCode 或 tenantId → 使用 JwtAuthenticationTokenFilter → 业务从 SecurityUtils/TenantConfigContext 取租户**。
+- 若某模块目前连「用户登录」都没有(纯 API Key 或内部调用),则只需在网关或入口 Filter 中根据 **请求头 X-Tenant-Code**(或类似)解析出 tenantId,设置到 **DynamicDataSourceContextHolder** 和 **TenantConfigContext**,不涉及 JWT 用户身份。
+
+---
+
+### 4.4 fs-framework 可做的增强(可选)
+
+| 增强项 | 说明 |
+|--------|------|
+| 租户解析器 | 提供 `TenantResolver` 接口:从 `HttpServletRequest`(域名/请求头/参数)解析出 `tenantCode` 或 `tenantId`,查主库得到 `TenantInfo`,供各模块 Filter 调用 |
+| TokenService 扩展 | `getLoginUser(request)` 支持从 `APPToken` 等请求头取 Token,避免各端重复写 |
+| 租户数据源生命周期 | 对 `TENANT_DS_CACHE` 做容量与过期策略,或增加「租户禁用时移除数据源」的联动 |
+| 健康检查 | 定时检测各租户数据源可用性,不可用时从缓存移除或告警 |
+
+---
+
+### 4.5 配置与 Redis
+
+- **租户配置**:继续使用各租户库的 `sys_config.projectConfig` + `TenantConfigContext`,无需改表结构;若新增配置项,在 projectConfig 的 JSON 中扩展即可。
+- **Redis**:  
+  - 所有写 Redis 的地方使用已配置 **TenantKeyRedisSerializer** 的 RedisTemplate。  
+  - 确保在 Filter 链中,在调用 `TokenService.getLoginUser()` 并设置好 `LoginUser.tenantId` 之后,再执行业务逻辑(这样 TenantKeyRedisSerializer 从 ThreadLocal 或 SecurityContext 取到的 tenantId 才正确)。  
+  - 若某模块在 Filter 之前就访问 Redis,需避免用带租户前缀的 Key,或改为在 Filter 内先设好 tenantId 再放行。
+
+---
+
+### 4.6 数据库与表结构(当前保持「按库隔离」)
+
+- 当前 **每租户独立库** 已满足多租户隔离,无需给业务表加 `tenant_id`。
+- 若未来要支持「共享库 + tenant_id」:
+  - 需在部分业务表增加 `tenant_id` 字段;
+  - 数据访问层统一加 tenant_id 条件(如 MyBatis 拦截器或 Mapper 中从 TenantConfigContext 取 tenantId 拼接);
+  - 与「按库隔离」二选一或分阶段迁移,此处不展开。
+
+---
+
+## 五、实施步骤建议
+
+1. **确定租户识别策略**  
+   - 管理端:保持 tenantCode 登录。  
+   - C 端:确定「登录参数 tenantCode」和/或「请求头 X-Tenant-Code」的优先级与使用场景。
+
+2. **fs-framework 小增强**  
+   - 增加租户解析(如 TenantResolver),TokenService 支持 APPToken(若需要)。  
+   - 可选:租户数据源缓存策略与清理逻辑。
+
+3. **先改 fs-user-app**  
+   - 加 framework 依赖,切数据源与 Filter,登录写 tenantId 到 Token,Controller/Service 用 getTenantId() 与 TenantConfigContext。  
+   - 联调:同一套代码,用不同 tenantCode 登录或带不同 X-Tenant-Code,验证数据与配置按租户隔离。
+
+4. **再改 fs-doctor-app、fs-company-app、fs-store 等**  
+   - 按同一模式接入 framework 的租户链路。
+
+5. **各 API 模块(fs-wx-api、fs-ad-api 等)**  
+   - 若对外需区分租户,在入口 Filter 或网关中解析 tenantId 并设置数据源与 TenantConfigContext;内部调用若已带 Token,则与现有 Filter 一致。
+
+6. **回归与监控**  
+   - 全链路回归:管理端、C 端、各 API 在不同租户下数据与配置隔离正确。  
+   - 监控:租户数据源数量、连接池、Redis 键前缀与过期。
+
+---
+
+## 六、涉及的主要文件/目录(参考)
+
+| 模块 | 主要改动文件/目录 |
+|------|-------------------|
+| fs-common | `LoginUser`/`LoginBody` 已有 tenantId/tenantCode;RedisConfig 确保 TenantKeyRedisSerializer 生效 |
+| fs-framework | `JwtAuthenticationTokenFilter`、`TenantDataSourceManager`、`DataSourceConfig`、`TokenService`;新增 TenantResolver(可选) |
+| fs-admin | 已支持多租户,仅需保证登录/Token 与文档一致,无需大改 |
+| fs-user-app | `pom.xml`、`DataSourceConfig`/数据源、`SecurityConfig`、登录 Controller/Service、`AppBaseController`、各业务 Controller 若需 tenantId 则用 getTenantId() |
+| fs-service | 租户与系统配置相关已存在;若 C 端登录要查主库 tenant_info,可复用 `TenantInfoService` |
+
+---
+
+按上述方案实施后,全代码模块可统一为「SaaS 版」:各入口带租户身份,按租户切库与使用租户配置,为后续计费、限流、租户级功能开关等打好基础。
+
+---
+
+## 七、定时任务模块 SaaS 化(fs-quartz 与 fs-qw-task)
+
+私有化部署时:每个客户独立库、独立服务器,定时任务跑在该客户自己的进程里,天然只操作本库。SaaS 化后:**一套进程、多租户多库**,定时任务需要「按租户执行」,即每个任务逻辑要对每个启用租户各执行一遍,且执行时数据源和租户配置为该租户的库与配置。
+
+---
+
+### 7.1 fs-quartz(管理端可配置的定时任务)
+
+**现状**:
+
+- 由 **fs-admin** 引入,Quartz 使用一个 DataSource(私有化时即该客户唯一库)。
+- 任务配置表 **sys_job**、日志表 **sys_job_log** 在该库;启动时 `SysJobServiceImpl.init()` 从该库 `selectJobAll()` 加载任务并注册到 Quartz。
+- 触发时无 HTTP 请求,无 `LoginUser`,无租户上下文;`JobInvokeUtil.invokeMethod(sysJob)` 调用的 Bean 方法访问的也是「当前默认数据源」。
+
+**SaaS 下的矛盾**:  
+若仍用「一个 DataSource」且用主库,则读不到各租户的 sys_job;若用某一租户库,则只能跑该租户任务。因此需要**显式按租户调度 + 按租户执行**。
+
+#### 方案 A:租户任务分发器(推荐,表结构不变)
+
+- **思路**:Quartz 只保留**一个**定时任务(例如每分钟执行一次),该任务不直接执行业务,而是做「租户分发」:
+  1. 从**主库**查所有启用且未过期的租户列表(`tenant_info`)。
+  2. 对每个租户:  
+     - 使用 `TenantDataSourceManager.switchTenant(tenantInfo)` 切到该租户库;  
+     - 从**当前数据源**(该租户库)查 `sys_job` 中状态为「正常」且**本分钟应触发**的任务(或查全部,在内存里用 Cron 判断是否到点);  
+     - 对每个到点任务执行 `JobInvokeUtil.invokeMethod(sysJob)`(此时线程上下文已是该租户库);  
+     - 写 `sys_job_log` 时仍在当前租户库,无需改表。
+  3. 执行完毕后清理 `DynamicDataSourceContextHolder`、`TenantConfigContext`。
+- **优点**:各租户的 sys_job / sys_job_log 仍保留在各自库,表结构、管理端「定时任务」功能无需大改;只需新增一个「分发器」Job 和对应的 cron(如 `0 * * * * ?` 每分钟)。
+- **需要改的点**:
+  - **fs-admin**(或 fs-framework)中:  
+    - 增加一个 Bean,例如 `TenantJobDispatcherJob`,实现 Quartz Job,内部逻辑为上述 1~3。  
+    - 该 Job 需要在**启动时**就注册到 Scheduler(可写死在代码里,或从主库一张「系统级任务表」里读唯一一条「分发器」配置)。  
+    - 分发器从主库查租户列表需用主库数据源(查 tenant_info 时先切到 MASTER,再循环里再切到各租户)。  
+  - **fs-quartz**:  
+    - 若希望「任务配置仍在各租户库」,则 **SysJobServiceImpl.init()** 不再从当前库 selectJobAll 注册所有任务,改为只注册「租户分发器」这一条;  
+    - 或者保留 init() 但仅当检测到是「主库」时只注册分发器,具体可根据你们是否把 sys_job 放在主库一条而定。  
+  - **数据源**:fs-admin 的 Quartz 使用的 DataSource 需为 **DynamicDataSource**(主库 + 各租户动态库),且调度线程执行分发器前要能访问主库与 TenantDataSourceManager。
+
+#### 方案 B:sys_job 上迁主库并带 tenant_id
+
+- **思路**:把 sys_job(和可选 sys_job_log)迁到**主库**,表增加 **tenant_id** 字段;Quartz 从主库加载**全部**任务,JobKey 用 `tenantId + jobId` 保证唯一;执行时从 JobDataMap 取出 tenantId,先 `switchTenant` + 设置 TenantConfigContext,再执行 invokeMethod。
+- **优点**:一个 Quartz 调度器、一套触发逻辑,与现有 Quartz 模型一致。  
+- **缺点**:要迁移表结构、数据;管理端「定时任务」界面需改为按租户过滤(且操作的是主库表);若希望「租户只能看自己的任务」需在权限上控制。
+
+**建议**:优先采用 **方案 A**,对现有私有化表结构零侵入,仅增加一个分发器 Job 和按租户循环执行的逻辑。
+
+---
+
+### 7.2 fs-qw-task(企微等固定节奏的定时任务)
+
+**现状**:
+
+- **独立 Spring Boot 应用**,依赖 fs-service,使用 **@Scheduled** 写死 cron(如每小时、每天凌晨等)。
+- 数据源为单 master + SOP,**无租户概念**;任务直接调 Mapper/Service,操作的是「当前连接的那一个库」。
+
+**SaaS 下目标**:  
+同一套代码、同一进程,但每个定时任务要对**每个启用租户**各执行一遍,且执行时该租户的库、企微配置等来自该租户的 `sys_config`(TenantConfigContext)。
+
+#### 改法:按租户遍历执行 + 租户上下文
+
+1. **引入多租户能力**  
+   - 依赖 **fs-framework**(或至少引入 **TenantDataSourceManager**、主库数据源、能查 `tenant_info` 的 Mapper)。  
+   - 数据源改为 **DynamicDataSource**(主库 + 租户动态库),与 fs-admin 一致;应用启动时只配主库,租户库由 TenantDataSourceManager 在运行时按需加入。
+
+2. **封装「按租户执行」工具**  
+   - 在 fs-framework 或 fs-qw-task 内提供工具类/Bean,例如:  
+     `TenantTaskRunner.runForEachTenant(Consumer<TenantInfo> action)`  
+   - 逻辑:从主库查所有 status=启用、未过期的租户列表 → for 每个租户:  
+     - `TenantDataSourceManager.switchTenant(tenantInfo)`;  
+     - 从该租户库查 `sys_config.projectConfig` 写入 `TenantConfigContext`(可复用现有 ProjectConfig.loadTenantConfigsFromContext 逻辑);  
+     - 执行 `action.accept(tenantInfo)`;  
+     - finally 中清理 `DynamicDataSourceContextHolder`、`TenantConfigContext`。  
+   - 若某租户执行抛错,可记录日志并继续下一个租户,避免一个租户异常导致其余不执行。
+
+3. **改造每个 @Scheduled 方法**  
+   - 将原有「无租户」的一坨逻辑封装成一个方法或 Lambda。  
+   - 在 @Scheduled 方法里只写一行:  
+     `tenantTaskRunner.runForEachTenant(tenantInfo -> { 原有逻辑 });`  
+   - 例如:  
+     - `qwCheckSopRuleTime()` → `tenantTaskRunner.runForEachTenant(t -> qwSopService.checkSopRuleTime());`  
+     - `addTag()` → `tenantTaskRunner.runForEachTenant(t -> qwSopTagService.addTag());`  
+   - 这样原有业务代码(Mapper、Service)无需改,只要在「有租户上下文」的线程里执行即可。
+
+4. **Redis**  
+   - 若任务里有用到 Redis,需保证 RedisTemplate 使用 **TenantKeyRedisSerializer**,且执行时 ThreadLocal 或上下文里能拿到当前 tenantId(在 runForEachTenant 内 set 一次即可)。
+
+**小结**:fs-qw-task 不改任务业务逻辑,只加一层「按租户执行」的壳;数据源与租户配置与 fs-admin 对齐即可。
+
+---
+
+### 7.3 实施顺序建议
+
+1. **先做 fs-qw-task**:依赖 framework、切 DynamicDataSource、实现 TenantTaskRunner、逐个 @Scheduled 包一层 runForEachTenant,联调多租户数据与配置隔离。  
+2. **再做 fs-quartz**:在 fs-admin 中实现「租户任务分发器」Job,Quartz 只调度该 Job;分发器内按租户切库并执行各租户 sys_job 中到点任务;视需要调整 init() 与任务管理界面(若仍用各租户库 sys_job,界面按当前登录租户查即可)。
+
+---
+
+### 7.4 涉及的主要文件/目录(定时任务)
+
+| 模块 | 改动要点 |
+|------|----------|
+| **fs-framework** | 可选:提供 `TenantTaskRunner`(或放在 fs-qw-task 内),依赖 TenantDataSourceManager、TenantInfoService/Mapper、TenantConfigContext。 |
+| **fs-admin** | Quartz 使用 DynamicDataSource;新增 `TenantJobDispatcherJob`;init() 只注册分发器或按策略注册。 |
+| **fs-quartz** | 若采用方案 A,可不改表与 Mapper,仅被分发器通过 JobInvokeUtil 调用;若采用方案 B,需 sys_job 增加 tenant_id 及迁移脚本。 |
+| **fs-qw-task** | 依赖 fs-framework;DataSource 改为 DynamicDataSource + 租户;新增 TenantTaskRunner;各 @Scheduled 改为 runForEachTenant(原有逻辑)。 |

+ 98 - 0
docs/admin与company模块SaaS改造说明.md

@@ -0,0 +1,98 @@
+# admin 与 company 模块 SaaS 改造说明
+
+本文档说明 **fs-admin**、**fs-company** 两模块在已有部分改造基础上的补充与完善点,以及本次已完成的改动。
+
+---
+
+## 一、改造前已有能力
+
+### fs-admin
+
+- 依赖 **fs-framework**,登录走 `SysLoginService.login(..., tenantCode)`,支持传 `tenantCode` 查租户、切库、`LoginUser.setTenantId`。
+- 请求链使用 framework 的 **JwtAuthenticationTokenFilter**:按 `tenantId` 切数据源、设置 **TenantConfigContext**、**ProjectConfig.loadTenantConfigsFromContext()**。
+- 租户管理入口:**TenantInfoController**(租户 CRUD)。
+
+### fs-company
+
+- 自有 **CompanyLoginService**:支持 `tenantCode` 登录、主库查租户、**TenantDataSourceManager.switchTenant**、登录成功后 **loginUser.setTenantId**。
+- 自有 **JwtAuthenticationTokenFilter**:按 `tenantId` 切库、设置 **TenantConfigContext**、**ProjectConfig.loadTenantConfigsFromContext()**。
+- 自有 **TenantDataSourceManager**、**DynamicDataSource**,数据源配置含 master + sop。
+
+---
+
+## 二、本次补充与完善
+
+### 1. fs-common:SecurityUtils.getTenantId() 兼容 company
+
+- **问题**:company 使用的 `LoginUser` 是 **framework.security.LoginUser**,不是 common 的 **LoginUser**。Redis 的 **TenantKeyRedisSerializer** 依赖 `SecurityUtils.getTenantId()` 给 key 加租户前缀,若只判断 `principal instanceof LoginUser`(common),company 登录后 principal 不匹配,导致 Redis key 无租户前缀。
+- **改动**:在 **fs-common** 的 `SecurityUtils.getTenantId()` 中,除 `LoginUser` 外,对任意 principal 用反射调用 `getTenantId()`,若返回 `Long` 则使用,从而兼容 framework 的 LoginUser。
+- **文件**:`fs-common/.../SecurityUtils.java`。
+
+### 2. fs-admin:租户管理接口强制走主库
+
+- **问题**:租户表 **tenant_info** 只在主库;若管理员以某租户身份登录,当前数据源为租户库,访问「租户管理」接口会查租户库,可能无该表或数据错误。
+- **改动**:在 **TenantInfoController** 上增加 **@DataSource(DataSourceType.MASTER)**,该类所有接口强制走主库。
+- **文件**:`fs-admin/.../tenant/TenantInfoController.java`。
+
+### 3. fs-company:数据源映射统一为字符串 key
+
+- **问题**:**DataSourceConfig** 中 `targetDataSources` 使用 `DataSourceType.MASTER`(枚举)为 key,而 **DynamicDataSourceContextHolder** 使用 **MASTER.name()**(字符串)。为与 framework 及后续维护一致,建议统一为字符串 key。
+- **改动**:将 `targetDataSources.put(DataSourceType.MASTER, ...)` 改为 `put(DataSourceType.MASTER.name(), ...)`。
+- **文件**:`fs-company/.../config/DataSourceConfig.java`。
+
+### 4. fs-company:登录后 Redis Token 按租户隔离
+
+- **问题**:Token 缓存的 Redis key 为 `company_login_tokens:uuid`,多租户下不同租户相同 uuid 会互相覆盖;且 **TenantKeyRedisSerializer** 对 `login_tokens` 有特殊处理,而 company 使用 `company_login_tokens`,存/取时若未带租户信息,无法按租户隔离。
+- **改动**:
+  1. **TokenService**:  
+     - 生成 token 时若 `loginUser.getTenantId() != null`,将 **tenantId 写入 JWT claims**。  
+     - **getTokenKey** 改为 `getTokenKey(Long tenantId, String uuid)`:tenantId 非空时返回 `tenantid:{tenantId}:company_login_tokens:{uuid}`(与 TenantKeyRedisSerializer 约定一致,以 `tenantid:` 开头不再加前缀)。  
+     - **refreshToken** 使用 `getTokenKey(loginUser.getTenantId(), loginUser.getToken())` 写入 Redis。  
+     - **getLoginUser** 从 JWT 解析出 uuid 与 tenantId,用 `getTokenKey(tenantId, uuid)` 取 Redis。  
+     - **delLoginUser** 先解析 token 得到 tenantId 与 uuid,再按 `getTokenKey(tenantId, uuid)` 删除。
+  2. **CompanyLoginService**:在调用 **tokenService.createToken(loginUser)** 前,将当前 **LoginUser** 设置到 **SecurityContext**,便于其他依赖 `getTenantId()` 的逻辑(如 Redis 序列化)在需要时能拿到租户。
+- **文件**:`fs-company/.../service/TokenService.java`、`CompanyLoginService.java`。
+
+---
+
+## 三、改造后行为小结
+
+| 模块 | 能力 | 说明 |
+|------|------|------|
+| **fs-admin** | 登录带 tenantCode | 与原有一致,framework 的 SysLoginService 处理。 |
+| **fs-admin** | 请求按 tenantId 切库 + 租户配置 | 与原有一致,framework 的 JwtAuthenticationTokenFilter 处理。 |
+| **fs-admin** | 租户管理接口 | 强制主库,避免租户身份下误查租户库。 |
+| **fs-company** | 登录带 tenantCode | 与原有一致,CompanyLoginService 处理。 |
+| **fs-company** | 请求按 tenantId 切库 + 租户配置 | 与原有一致,company 的 JwtAuthenticationTokenFilter 处理。 |
+| **fs-company** | Redis Token 按租户隔离 | Token 存/取/删均带 tenantId,多租户下互不覆盖。 |
+| **fs-common** | getTenantId() | 兼容 admin(common LoginUser)与 company(framework LoginUser)。 |
+
+---
+
+## 四、建议自测点
+
+1. **admin**  
+   - 带 tenantCode 登录 → 后续请求是否走对应租户库、租户配置是否生效。  
+   - 以租户身份登录后访问「租户管理」列表/编辑 → 是否始终查主库、数据是否正确。
+
+2. **company**  
+   - 不同 tenantCode 登录 → 两户的 token 是否互不影响(Redis key 不同、互不踢出)。  
+   - 带 tenantCode 登录后请求业务接口 → 是否走对应租户库、租户配置是否生效。  
+   - 登出 → 对应 Redis key 是否按租户正确删除。
+
+3. **Redis**  
+   - 多租户下 Redis 缓存(含 token、业务 key)是否均带租户前缀或按租户隔离,无串户。
+
+---
+
+## 五、涉及文件清单
+
+| 文件 | 改动说明 |
+|------|----------|
+| `fs-common/.../SecurityUtils.java` | getTenantId() 增加反射兼容 framework LoginUser |
+| `fs-admin/.../tenant/TenantInfoController.java` | 类上增加 @DataSource(DataSourceType.MASTER) |
+| `fs-company/.../config/DataSourceConfig.java` | MASTER 使用 .name() 作为 map key |
+| `fs-company/.../service/TokenService.java` | JWT 带 tenantId、getTokenKey(tenantId,uuid)、存/取/删按租户 key |
+| `fs-company/.../service/CompanyLoginService.java` | 生成 token 前设置 SecurityContext |
+
+与整体 SaaS 方案的关系见 [SaaS改造方案.md](./SaaS改造方案.md)。

+ 130 - 0
docs/定时任务模块SaaS化方案.md

@@ -0,0 +1,130 @@
+# 定时任务模块 SaaS 化方案
+
+本文档说明在从「私有化部署(每客户独立库、独立服务器)」转为 SaaS 后,**fs-quartz**(管理端可配置定时任务)与 **fs-qw-task**(企微等固定节奏定时任务)的改造思路与落地要点。与整体 SaaS 方案的关系见 [SaaS改造方案.md](./SaaS改造方案.md)。
+
+---
+
+## 一、背景与目标
+
+**私有化时**:每个客户独立库、独立服务器,定时任务跑在该客户自己的进程里,天然只操作本库,无需考虑多租户。
+
+**SaaS 化后**:一套进程、多租户多库,定时任务需要「**按租户执行**」——即同一任务逻辑要对**每个启用租户**各执行一遍,且每次执行时:
+- 数据源为该租户的库;
+- 租户级配置(如企微、微信)来自该租户的 `sys_config`(TenantConfigContext)。
+
+本方案围绕 **fs-quartz** 和 **fs-qw-task** 两个模块,说明如何在不改变业务逻辑的前提下,通过「租户分发 / 按租户遍历」实现上述目标。
+
+---
+
+## 二、fs-quartz(管理端可配置的定时任务)
+
+### 2.1 现状
+
+- 由 **fs-admin** 引入,Quartz 使用一个 DataSource(私有化时即该客户唯一库)。
+- 任务配置表 **sys_job**、日志表 **sys_job_log** 在该库;启动时 `SysJobServiceImpl.init()` 从该库 `selectJobAll()` 加载任务并注册到 Quartz。
+- 触发时无 HTTP 请求,无 `LoginUser`,无租户上下文;`JobInvokeUtil.invokeMethod(sysJob)` 调用的 Bean 方法访问的也是「当前默认数据源」。
+
+### 2.2 SaaS 下的矛盾
+
+若仍用「一个 DataSource」且用主库,则读不到各租户的 sys_job;若用某一租户库,则只能跑该租户任务。因此需要**显式按租户调度 + 按租户执行**。
+
+### 2.3 方案 A:租户任务分发器(推荐,表结构不变)
+
+**思路**:Quartz 只保留**一个**定时任务(例如每分钟执行一次),该任务不直接执行业务,而是做「租户分发」:
+
+1. 从**主库**查所有启用且未过期的租户列表(`tenant_info`)。
+2. 对每个租户:
+   - 使用 `TenantDataSourceManager.switchTenant(tenantInfo)` 切到该租户库;
+   - 从**当前数据源**(该租户库)查 `sys_job` 中状态为「正常」且**本分钟应触发**的任务(或查全部,在内存里用 Cron 判断是否到点);
+   - 对每个到点任务执行 `JobInvokeUtil.invokeMethod(sysJob)`(此时线程上下文已是该租户库);
+   - 写 `sys_job_log` 时仍在当前租户库,无需改表。
+3. 执行完毕后清理 `DynamicDataSourceContextHolder`、`TenantConfigContext`。
+
+**优点**:各租户的 sys_job / sys_job_log 仍保留在各自库,表结构、管理端「定时任务」功能无需大改;只需新增一个「分发器」Job 和对应的 cron(如 `0 * * * * ?` 每分钟)。
+
+**需要改的点**:
+
+| 位置 | 改动内容 |
+|------|----------|
+| **fs-admin**(或 fs-framework) | 增加一个 Bean,例如 `TenantJobDispatcherJob`,实现 Quartz Job,内部逻辑为上述 1~3;该 Job 需在**启动时**就注册到 Scheduler(可写死在代码里,或从主库「系统级任务表」读唯一一条「分发器」配置);分发器从主库查租户列表时需使用主库数据源(查 tenant_info 时先切到 MASTER,循环内再切到各租户)。 |
+| **fs-quartz** | 若任务配置仍在各租户库,则 **SysJobServiceImpl.init()** 不再从当前库 selectJobAll 注册所有任务,改为只注册「租户分发器」这一条;或保留 init() 但仅当检测到是「主库」时只注册分发器。 |
+| **数据源** | fs-admin 的 Quartz 使用的 DataSource 需为 **DynamicDataSource**(主库 + 各租户动态库),且调度线程执行分发器前要能访问主库与 TenantDataSourceManager。 |
+
+### 2.4 方案 B:sys_job 上迁主库并带 tenant_id
+
+**思路**:把 sys_job(和可选 sys_job_log)迁到**主库**,表增加 **tenant_id** 字段;Quartz 从主库加载**全部**任务,JobKey 用 `tenantId + jobId` 保证唯一;执行时从 JobDataMap 取出 tenantId,先 `switchTenant` + 设置 TenantConfigContext,再执行 invokeMethod。
+
+**优点**:一个 Quartz 调度器、一套触发逻辑,与现有 Quartz 模型一致。
+
+**缺点**:要迁移表结构、数据;管理端「定时任务」界面需改为按租户过滤(且操作的是主库表);若希望「租户只能看自己的任务」需在权限上控制。
+
+**建议**:优先采用 **方案 A**,对现有私有化表结构零侵入,仅增加一个分发器 Job 和按租户循环执行的逻辑。
+
+---
+
+## 三、fs-qw-task(企微等固定节奏的定时任务)
+
+### 3.1 现状
+
+- **独立 Spring Boot 应用**,依赖 fs-service,使用 **@Scheduled** 写死 cron(如每小时、每天凌晨等)。
+- 数据源为单 master + SOP,**无租户概念**;任务直接调 Mapper/Service,操作的是「当前连接的那一个库」。
+
+### 3.2 SaaS 下目标
+
+同一套代码、同一进程,但每个定时任务要对**每个启用租户**各执行一遍,且执行时该租户的库、企微配置等来自该租户的 `sys_config`(TenantConfigContext)。
+
+### 3.3 改法:按租户遍历执行 + 租户上下文
+
+1. **引入多租户能力**
+   - 依赖 **fs-framework**(或至少引入 **TenantDataSourceManager**、主库数据源、能查 `tenant_info` 的 Mapper)。
+   - 数据源改为 **DynamicDataSource**(主库 + 租户动态库),与 fs-admin 一致;应用启动时只配主库,租户库由 TenantDataSourceManager 在运行时按需加入。
+
+2. **封装「按租户执行」工具**
+   - 在 fs-framework 或 fs-qw-task 内提供工具类/Bean,例如:  
+     `TenantTaskRunner.runForEachTenant(Consumer<TenantInfo> action)`
+   - 逻辑:从主库查所有 status=启用、未过期的租户列表 → for 每个租户:
+     - `TenantDataSourceManager.switchTenant(tenantInfo)`;
+     - 从该租户库查 `sys_config.projectConfig` 写入 `TenantConfigContext`(可复用现有 ProjectConfig.loadTenantConfigsFromContext 逻辑);
+     - 执行 `action.accept(tenantInfo)`;
+     - finally 中清理 `DynamicDataSourceContextHolder`、`TenantConfigContext`。
+   - 若某租户执行抛错,可记录日志并继续下一个租户,避免一个租户异常导致其余不执行。
+
+3. **改造每个 @Scheduled 方法**
+   - 将原有「无租户」的一坨逻辑封装成一个方法或 Lambda。
+   - 在 @Scheduled 方法里只写一行:  
+     `tenantTaskRunner.runForEachTenant(tenantInfo -> { 原有逻辑 });`
+   - 示例:
+     - `qwCheckSopRuleTime()` → `tenantTaskRunner.runForEachTenant(t -> qwSopService.checkSopRuleTime());`
+     - `addTag()` → `tenantTaskRunner.runForEachTenant(t -> qwSopTagService.addTag());`
+     - `wxSop()`、`sendQwGroupMsgTask()` 等同理。
+   - 原有业务代码(Mapper、Service)无需改,只要在「有租户上下文」的线程里执行即可。
+
+4. **Redis**
+   - 若任务里有用到 Redis,需保证 RedisTemplate 使用 **TenantKeyRedisSerializer**,且执行时 ThreadLocal 或上下文里能拿到当前 tenantId(在 runForEachTenant 内 set 一次即可)。
+
+**小结**:fs-qw-task 不改任务业务逻辑,只加一层「按租户执行」的壳;数据源与租户配置与 fs-admin 对齐即可。
+
+---
+
+## 四、实施顺序建议
+
+1. **先做 fs-qw-task**:依赖 framework、切 DynamicDataSource、实现 TenantTaskRunner、逐个 @Scheduled 包一层 runForEachTenant,联调多租户数据与配置隔离。
+2. **再做 fs-quartz**:在 fs-admin 中实现「租户任务分发器」Job,Quartz 只调度该 Job;分发器内按租户切库并执行各租户 sys_job 中到点任务;视需要调整 init() 与任务管理界面(若仍用各租户库 sys_job,界面按当前登录租户查即可)。
+
+---
+
+## 五、涉及的主要文件/目录
+
+| 模块 | 改动要点 |
+|------|----------|
+| **fs-framework** | 可选:提供 `TenantTaskRunner`(或放在 fs-qw-task 内),依赖 TenantDataSourceManager、TenantInfoService/Mapper、TenantConfigContext。 |
+| **fs-admin** | Quartz 使用 DynamicDataSource;新增 `TenantJobDispatcherJob`;init() 只注册分发器或按策略注册。 |
+| **fs-quartz** | 若采用方案 A,可不改表与 Mapper,仅被分发器通过 JobInvokeUtil 调用;若采用方案 B,需 sys_job 增加 tenant_id 及迁移脚本。 |
+| **fs-qw-task** | 依赖 fs-framework;DataSource 改为 DynamicDataSource + 租户;新增 TenantTaskRunner;各 @Scheduled 改为 runForEachTenant(原有逻辑)。 |
+
+---
+
+## 六、与主方案的关系
+
+- 租户数据源、租户配置上下文、主库 tenant_info 等能力均依赖 [SaaS改造方案.md](./SaaS改造方案.md) 中的 **fs-framework** 与 **fs-service** 已有实现。
+- 定时任务模块仅在此基础上增加「无 HTTP 请求场景下的租户上下文设置」与「按租户循环执行」的封装,不改变整体多租户架构。

+ 83 - 0
docs/方案.md

@@ -0,0 +1,83 @@
+## 现状结论
+
+- **管理端(fs-admin)** 已具备多租户:`tenant_info`、按库隔离、登录带 `tenantCode`、JWT 带 `tenantId`、请求里切库并注入 `TenantConfigContext`。
+- **C 端(fs-user-app 等)** 还未接多租户:用自有 `JwtUtils` + `APPToken`,没有 `tenantId`、不切库、也不用租户配置。
+
+---
+
+## 改造目标(SaaS 版要达成什么)
+
+1. 所有入口(admin、user-app、doctor-app、company、store、各 API)请求都带**租户身份**,按租户切库或使用租户配置。
+2. 租户识别方式统一(管理端继续用登录 `tenantCode`,C 端用登录参数和/或请求头如 `X-Tenant-Code`)。
+3. Token/请求上下文中统一带 `tenantId`,用 framework 的过滤器做切库和 `TenantConfigContext` 注入。
+
+---
+
+## 主要要改的地方与改法
+
+| 类别 | 改什么 | 怎么改 |
+|------|--------|--------|
+| **租户识别** | C 端如何知道是哪个租户 | 方案 A:登录接口增加 `tenantCode`,写入 Token;方案 B:请求头 `X-Tenant-Code`,在 Filter 里解析出 tenantId。可 A+B 组合。 |
+| **fs-user-app** | 接入多租户链路 | 1)依赖 fs-framework;2)数据源改为用 framework 的 DynamicDataSource(含租户库);3)登录时查主库 `tenant_info`、切租户、`LoginUser.setTenantId()`,用 `TokenService.createToken()` 生成 JWT;4)在 Security 里加入 `JwtAuthenticationTokenFilter`,保证每次请求按 tenantId 切库并设置 `TenantConfigContext`;5)`TokenService.getLoginUser()` 支持从 `APPToken` 取 Token;6)`AppBaseController` 增加 `getTenantId()`。 |
+| **fs-doctor-app / company / store 等** | 与 user-app 一致支持多租户 | 同样:依赖 framework、用 DynamicDataSource、登录/请求带 tenantCode 或 tenantId、使用 `JwtAuthenticationTokenFilter`、业务从 `SecurityUtils`/`TenantConfigContext` 取租户。 |
+| **fs-framework(可选增强)** | 租户解析与 Token 兼容 | 新增 `TenantResolver`(从请求头/域名解析 tenantCode→tenantId);`TokenService` 支持从 `APPToken` 取 Token。 |
+| **Redis** | 多租户下 Key 隔离 | 使用已配置 `TenantKeyRedisSerializer` 的 RedisTemplate,并保证在 Filter 里先设置好 `LoginUser.tenantId` 再执行业务。 |
+| **数据库** | 保持现有隔离方式 | 继续「每租户一库」,无需给业务表加 `tenant_id`;若以后要做共享库再单独规划。 |
+
+---
+
+## 建议实施顺序
+
+1. 定好 C 端租户识别策略(登录参数 vs 请求头)。
+2. 在 fs-framework 做小增强:租户解析(可选)、Token 支持 APPToken。
+3. **先改 fs-user-app**:接 framework、改数据源、登录写 tenantId、加 Filter、基类加 getTenantId(),联调不同 tenantCode 验证隔离。
+4. 再按同一模式改 fs-doctor-app、fs-company-app、fs-store 等。
+5. 各 API 模块在入口 Filter 或网关上解析 tenantId 并设置数据源与 `TenantConfigContext`。
+
+完整步骤、架构图和涉及文件清单都在 **`docs/SaaS改造方案.md`** 里,可按该文档逐步落地。如果你希望从某一块先动手(例如先只改 user-app 或先做 TenantResolver),可以说一下,我可以按那一块给出更具体的改法或示例代码。
+
+
+查看 fs-quartz 和 fs-qw-task 的当前实现,以便给出 SaaS 下定时任务的处理方案。
+
+
+## 一、问题本质
+
+- **私有化**:每个客户独立库、独立服务器,定时任务只连本客户库,没有多租户问题。
+- **SaaS**:一套进程、多租户多库,定时任务必须「按租户执行」——同一任务逻辑要对**每个启用租户**各跑一遍,且每次跑的是该租户的库和配置。
+
+---
+
+## 二、fs-quartz(管理端可配置的定时任务)
+
+- **现状**:任务配置在 `sys_job`(当前是单库),Quartz 用单一 DataSource 加载并触发,执行时没有租户上下文。
+- **推荐做法(方案 A:租户任务分发器)**  
+  - Quartz 只注册**一个**定时任务(例如每分钟一次),不直接执行业务,只做「按租户分发」:  
+    1. 从**主库**查所有启用租户;  
+    2. 对每个租户:`TenantDataSourceManager.switchTenant(tenantInfo)` 切到该租户库 → 从**该租户库**的 `sys_job` 里查出本分钟该触发的任务 → 对每个任务执行 `JobInvokeUtil.invokeMethod(sysJob)`;  
+    3. 执行完后清理数据源和租户上下文。  
+  - 这样 **sys_job / sys_job_log 仍保留在各租户库**,表结构不用改,只是多了一个「分发器」Job。  
+- **需要改的地方**:  
+  - fs-admin:Quartz 使用 **DynamicDataSource**(主库 + 租户动态库);新增一个 Bean(如 `TenantJobDispatcherJob`),实现上述 1~3,并在启动时只把该分发器注册到 Scheduler。  
+  - fs-quartz:`SysJobServiceImpl.init()` 改为只注册这一条「分发器」任务,不再从当前库 `selectJobAll` 注册所有任务(具体是否保留 init 可根据你是否把 sys_job 迁主库而定)。
+
+---
+
+## 三、fs-qw-task(企微等 @Scheduled 定时任务)
+
+- **现状**:独立应用,用 **@Scheduled** 写死 cron,连的是单 master + SOP,没有租户。
+- **做法:按租户遍历 + 租户上下文**  
+  1. **接入多租户**:引入 fs-framework,数据源改为 **DynamicDataSource**(主库 + 租户动态库),与 fs-admin 一致。  
+  2. **封装执行器**:提供 `TenantTaskRunner.runForEachTenant(Consumer<TenantInfo> action)`:从主库查启用租户 → 对每个租户切库、设置 `TenantConfigContext` → 执行 `action` → 清理上下文。  
+  3. **改造每个 @Scheduled**:原来整段任务逻辑包一层,例如:  
+     - `tenantTaskRunner.runForEachTenant(t -> qwSopService.checkSopRuleTime());`  
+     - 其它如 `addTag()`、`wxSop()`、`sendQwGroupMsgTask()` 等同理,都包在 `runForEachTenant` 里。  
+  4. 业务 Mapper/Service 不用改,只要在「已切到该租户库 + 已设置租户配置」的线程里执行即可;若用 Redis,需保证带租户前缀(TenantKeyRedisSerializer)且上下文中有 tenantId。
+
+---
+
+## 四、实施顺序建议
+
+1. **先做 fs-qw-task**:接 framework、DynamicDataSource、实现 `TenantTaskRunner`、逐个 @Scheduled 包一层 `runForEachTenant`,验证多租户数据与配置隔离。  
+2. **再做 fs-quartz**:在 fs-admin 里实现「租户任务分发器」Job,Quartz 只调度这一条;分发器内按租户切库并执行各租户库中到点的 sys_job。
+
+更细的步骤、方案 B(sys_job 上迁主库 + tenant_id)以及涉及的文件清单,都在 **`docs/SaaS改造方案.md`** 的 **第七章** 里,可直接按文档落地。

+ 63 - 0
fs-admin/src/main/java/com/fs/quartz/saas/QuartzSaaSConfig.java

@@ -0,0 +1,63 @@
+package com.fs.quartz.saas;
+
+import com.fs.common.constant.ScheduleConstants;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.CronScheduleBuilder;
+import org.quartz.CronTrigger;
+import org.quartz.JobBuilder;
+import org.quartz.JobDetail;
+import org.quartz.Scheduler;
+import org.quartz.TriggerBuilder;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.Resource;
+
+/**
+ * SaaS 模式下 Quartz 仅注册「租户任务分发器」一条任务,由分发器按租户切库执行各租户 sys_job。
+ */
+@Slf4j
+@Configuration
+@DependsOn("sysJobServiceImpl")
+public class QuartzSaaSConfig {
+
+    private static final String DISPATCHER_JOB_NAME = "TenantJobDispatcher";
+    private static final String DISPATCHER_GROUP = "SAAS_DISPATCHER";
+    private static final String DEFAULT_DISPATCHER_CRON = "0 * * * * ?";
+
+    @Value("${saas.quartz.tenant-dispatcher-only:false}")
+    private boolean tenantDispatcherOnly;
+
+    @Value("${saas.quartz.dispatcher-cron:" + DEFAULT_DISPATCHER_CRON + "}")
+    private String dispatcherCron;
+
+    @Resource
+    private Scheduler scheduler;
+
+    @PostConstruct
+    public void registerDispatcherIfSaaS() {
+        if (!tenantDispatcherOnly) {
+            return;
+        }
+        try {
+            if (scheduler.checkExists(org.quartz.JobKey.jobKey(DISPATCHER_JOB_NAME, DISPATCHER_GROUP))) {
+                log.info("[SaaS Quartz] 租户分发器任务已存在,跳过注册");
+                return;
+            }
+            JobDetail jobDetail = JobBuilder.newJob(TenantJobDispatcherJob.class)
+                    .withIdentity(DISPATCHER_JOB_NAME, DISPATCHER_GROUP)
+                    .build();
+            CronTrigger trigger = TriggerBuilder.newTrigger()
+                    .withIdentity(ScheduleConstants.TASK_CLASS_NAME + DISPATCHER_JOB_NAME, DISPATCHER_GROUP)
+                    .withSchedule(CronScheduleBuilder.cronSchedule(dispatcherCron))
+                    .build();
+            scheduler.scheduleJob(jobDetail, trigger);
+            log.info("[SaaS Quartz] 租户任务分发器已注册, cron={}", dispatcherCron);
+        } catch (Exception e) {
+            log.error("[SaaS Quartz] 注册租户任务分发器失败", e);
+            throw new IllegalStateException("注册租户任务分发器失败", e);
+        }
+    }
+}

+ 101 - 0
fs-admin/src/main/java/com/fs/quartz/saas/TenantJobDispatcherJob.java

@@ -0,0 +1,101 @@
+package com.fs.quartz.saas;
+
+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.quartz.domain.SysJob;
+import com.fs.quartz.mapper.SysJobMapper;
+import com.fs.quartz.util.CronUtils;
+import com.fs.quartz.util.JobInvokeUtil;
+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 com.fs.common.utils.spring.SpringUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.Job;
+import org.quartz.JobExecutionContext;
+import org.quartz.JobExecutionException;
+
+import java.util.Date;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * SaaS 租户定时任务分发器:每分钟执行一次,从主库查启用租户,逐租户切库并执行该租户库中本分钟到点的 sys_job。
+ */
+@Slf4j
+public class TenantJobDispatcherJob implements Job {
+
+    @Override
+    public void execute(JobExecutionContext context) throws JobExecutionException {
+        TenantDataSourceManager tenantDataSourceManager = SpringUtils.getBean(TenantDataSourceManager.class);
+        TenantInfoService tenantInfoService = SpringUtils.getBean(TenantInfoService.class);
+        SysJobMapper sysJobMapper = SpringUtils.getBean(SysJobMapper.class);
+        SysConfigMapper sysConfigMapper = SpringUtils.getBean(SysConfigMapper.class);
+
+        try {
+            // 1. 切到主库查启用且未过期的租户
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            TenantInfo query = new TenantInfo();
+            query.setStatus(1);
+            List<TenantInfo> tenants = tenantInfoService.selectTenantInfoList(query);
+            if (tenants == null || tenants.isEmpty()) {
+                log.debug("[SaaS Quartz] 无启用租户,跳过本分钟分发");
+                return;
+            }
+            Date now = new Date();
+            List<TenantInfo> validTenants = tenants.stream()
+                    .filter(t -> t.getExpireTime() == null || !t.getExpireTime().before(now))
+                    .collect(Collectors.toList());
+
+            for (TenantInfo tenant : validTenants) {
+                try {
+                    dispatchForTenant(tenant, tenantDataSourceManager, sysJobMapper, sysConfigMapper);
+                } catch (Exception e) {
+                    log.error("[SaaS Quartz] 租户 tenantId={}, tenantCode={} 执行任务异常", tenant.getId(), tenant.getTenantCode(), e);
+                } finally {
+                    TenantConfigContext.clear();
+                    DynamicDataSourceContextHolder.clearDataSourceType();
+                }
+            }
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    private void dispatchForTenant(TenantInfo tenant, TenantDataSourceManager tenantDataSourceManager,
+                                  SysJobMapper sysJobMapper, SysConfigMapper sysConfigMapper) throws Exception {
+        tenantDataSourceManager.switchTenant(tenant);
+
+        SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+        if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+            TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()));
+        } else {
+            TenantConfigContext.set(null);
+        }
+        ProjectConfig.loadTenantConfigsFromContext();
+
+        List<SysJob> allJobs = sysJobMapper.selectJobAll();
+        if (allJobs == null || allJobs.isEmpty()) {
+            return;
+        }
+        List<SysJob> dueJobs = allJobs.stream()
+                .filter(j -> "0".equals(j.getStatus()))
+                .filter(j -> CronUtils.isDueInThisMinute(j.getCronExpression()))
+                .collect(Collectors.toList());
+
+        for (SysJob job : dueJobs) {
+            try {
+                JobInvokeUtil.invokeMethod(job);
+            } catch (Exception e) {
+                log.error("[SaaS Quartz] 租户 tenantId={} 任务 jobId={}, invokeTarget={} 执行异常",
+                        tenant.getId(), job.getJobId(), job.getInvokeTarget(), e);
+            }
+        }
+    }
+}

+ 5 - 2
fs-admin/src/main/java/com/fs/tenant/TenantInfoController.java

@@ -1,10 +1,12 @@
 package com.fs.tenant;
 
+import com.fs.common.annotation.DataSource;
 import com.fs.common.annotation.Log;
 import com.fs.common.core.controller.BaseController;
 import com.fs.common.core.domain.AjaxResult;
 import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
+import com.fs.common.enums.DataSourceType;
 import com.fs.common.utils.poi.ExcelUtil;
 import com.fs.tenant.domain.TenantInfo;
 import com.fs.tenant.service.TenantInfoService;
@@ -15,11 +17,12 @@ import org.springframework.web.bind.annotation.*;
 import java.util.List;
 
 /**
- * 租户基础信息Controller
- * 
+ * 租户基础信息Controller(SaaS 下租户表仅在主库,强制走主库)
+ *
  * @author fs
  * @date 2026-01-23
  */
+@DataSource(DataSourceType.MASTER)
 @RestController
 @RequestMapping("/tenant/tenant")
 public class TenantInfoController extends BaseController

+ 6 - 0
fs-admin/src/main/resources/application.yml

@@ -12,3 +12,9 @@ spring:
 #    active: druid-fby
     active: dev
 
+# SaaS 模式下定时任务:仅注册租户任务分发器,由分发器每分钟按租户切库执行各租户 sys_job(需主库有 tenant_info)
+# saas:
+#   quartz:
+#     tenant-dispatcher-only: true
+#     dispatcher-cron: "0 * * * * ?"
+

+ 18 - 5
fs-common/src/main/java/com/fs/common/utils/SecurityUtils.java

@@ -30,16 +30,29 @@ public class SecurityUtils
     }
 
     /**
-     * 租户id
+     * 租户id(SaaS 多租户场景下 Redis 等按租户隔离时使用)
+     * 兼容 common 的 LoginUser 与 framework 的 LoginUser(company 模块)
      *
-     * @return
+     * @return 当前登录用户的租户 ID,未登录或非多租户则为 null
      */
     public static Long getTenantId() {
         Authentication auth = SecurityContextHolder.getContext().getAuthentication();
-        if (auth != null && auth.getPrincipal() instanceof LoginUser) {
-            return ((LoginUser) auth.getPrincipal()).getTenantId();
+        if (auth == null || auth.getPrincipal() == null) {
+            return null;
+        }
+        Object principal = auth.getPrincipal();
+        if (principal instanceof LoginUser) {
+            return ((LoginUser) principal).getTenantId();
+        }
+        // 兼容 fs-company 等使用 framework.security.LoginUser 的场景(该类也有 getTenantId)
+        try {
+            java.lang.reflect.Method m = principal.getClass().getMethod("getTenantId");
+            Object v = m.invoke(principal);
+            if (v instanceof Long) {
+                return (Long) v;
+            }
+        } catch (Exception ignored) {
         }
-        // 系统或未登录
         return null;
     }
 

+ 1 - 1
fs-company/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -40,7 +40,7 @@ public class DataSourceConfig {
     @Primary
     public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("sopDataSource") DataSource sopDataSource) {
         Map<Object, Object> targetDataSources = new HashMap<>();
-        targetDataSources.put(DataSourceType.MASTER, masterDataSource);
+        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
         targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
         return new DynamicDataSource(masterDataSource, targetDataSources);
     }

+ 3 - 1
fs-company/src/main/java/com/fs/framework/service/CompanyLoginService.java

@@ -184,7 +184,9 @@ public class CompanyLoginService
             if (tenantInfo != null) {
                 loginUser.setTenantId(tenantInfo.getId());
             }
-            // 生成token
+            // 生成 token 前设置 SecurityContext,便于 Redis 写入时带上租户前缀(TenantKeyRedisSerializer 会读 getTenantId())
+            org.springframework.security.core.context.SecurityContextHolder.getContext()
+                    .setAuthentication(new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities()));
             return tokenService.createToken(loginUser);
         } finally {
             // 防止线程串库(必须)

+ 36 - 28
fs-company/src/main/java/com/fs/framework/service/TokenService.java

@@ -50,35 +50,30 @@ public class TokenService
     @Autowired
     private RedisCache redisCache;
 
+    /** JWT 中租户 ID 的 key,SaaS 下按租户隔离 Redis token */
+    private static final String CLAIM_TENANT_ID = "tenantId";
+
     /**
-     * 获取用户身份信息
+     * 获取用户身份信息(SaaS 下从 JWT 取 tenantId 以定位 Redis key)
      *
      * @return 用户信息
      */
     public LoginUser getLoginUser(HttpServletRequest request)
     {
-        // 获取请求携带的令牌
         String token = getToken(request);
-        if (StringUtils.isNotEmpty(token))
-        {
-            Claims claims = parseToken(token);
-            // 解析对应的权限以及用户信息
-            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
-            String userKey = getTokenKey(uuid);
-            LoginUser user = redisCache.getCacheObject(userKey);
-            return user;
+        if (StringUtils.isEmpty(token)) {
+            token = getUrlToken(request);
         }
-        token=getUrlToken(request);
-        if (StringUtils.isNotEmpty(token))
-        {
-            Claims claims = parseToken(token);
-            // 解析对应的权限以及用户信息
-            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
-            String userKey = getTokenKey(uuid);
-            LoginUser user = redisCache.getCacheObject(userKey);
-            return user;
+        if (StringUtils.isNotEmpty(token)) {
+            try {
+                Claims claims = parseToken(token);
+                String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+                Long tenantId = claims.get(CLAIM_TENANT_ID) != null ? ((Number) claims.get(CLAIM_TENANT_ID)).longValue() : null;
+                String userKey = getTokenKey(tenantId, uuid);
+                return redisCache.getCacheObject(userKey);
+            } catch (Exception ignored) {
+            }
         }
-
         return null;
     }
 
@@ -94,14 +89,19 @@ public class TokenService
     }
 
     /**
-     * 删除用户身份信息
+     * 删除用户身份信息(SaaS 下需从 token 解析 tenantId 以定位 Redis key)
      */
     public void delLoginUser(String token)
     {
-        if (StringUtils.isNotEmpty(token))
-        {
-            String userKey = getTokenKey(token);
-            redisCache.deleteObject(userKey);
+        if (StringUtils.isEmpty(token)) {
+            return;
+        }
+        try {
+            Claims claims = parseToken(token);
+            String uuid = (String) claims.get(Constants.COMPANY_LOGIN_USER_KEY);
+            Long tenantId = claims.get(CLAIM_TENANT_ID) != null ? ((Number) claims.get(CLAIM_TENANT_ID)).longValue() : null;
+            redisCache.deleteObject(getTokenKey(tenantId, uuid));
+        } catch (Exception ignored) {
         }
     }
 
@@ -120,6 +120,9 @@ public class TokenService
 
         Map<String, Object> claims = new HashMap<>();
         claims.put(Constants.COMPANY_LOGIN_USER_KEY, token);
+        if (loginUser.getTenantId() != null) {
+            claims.put(CLAIM_TENANT_ID, loginUser.getTenantId());
+        }
         return createToken(claims);
     }
 
@@ -148,8 +151,7 @@ public class TokenService
     {
         loginUser.setLoginTime(System.currentTimeMillis());
         loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
-        // 根据uuid将loginUser缓存
-        String userKey = getTokenKey(loginUser.getToken());
+        String userKey = getTokenKey(loginUser.getTenantId(), loginUser.getToken());
         redisCache.setCacheObject(userKey, loginUser, expireTime, TimeUnit.MINUTES);
     }
 
@@ -229,8 +231,14 @@ public class TokenService
         return token;
     }
 
-    private String getTokenKey(String uuid)
+    /**
+     * Redis 中 loginUser 的 key。SaaS 下带 tenantId 前缀,与 TenantKeyRedisSerializer 约定一致(tenantid: 开头不再加前缀)。
+     */
+    private String getTokenKey(Long tenantId, String uuid)
     {
+        if (tenantId != null) {
+            return "tenantid:" + tenantId + ":company_login_tokens:" + uuid;
+        }
         return Constants.COMPANY_LOGIN_TOKEN_KEY + uuid;
     }
 }

+ 10 - 0
fs-quartz/src/main/java/com/fs/quartz/service/impl/SysJobServiceImpl.java

@@ -7,6 +7,7 @@ import org.quartz.JobKey;
 import org.quartz.Scheduler;
 import org.quartz.SchedulerException;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.fs.common.constant.ScheduleConstants;
@@ -31,13 +32,22 @@ public class SysJobServiceImpl implements ISysJobService
     @Autowired
     private SysJobMapper jobMapper;
 
+    /** SaaS 模式:仅清空调度器,不从当前库加载任务;由 fs-admin 的 QuartzSaaSConfig 注册租户任务分发器,由分发器按租户执行各库 sys_job */
+    @Value("${saas.quartz.tenant-dispatcher-only:false}")
+    private boolean tenantDispatcherOnly;
+
     /**
      * 项目启动时,初始化定时器 主要是防止手动修改数据库导致未同步到定时任务处理(注:不能手动修改数据库ID和任务组名,否则会导致脏数据)
+     * SaaS 模式下仅清空调度器,具体任务由租户分发器按租户执行。
      */
     @PostConstruct
     public void init() throws SchedulerException, TaskException
     {
         scheduler.clear();
+        if (tenantDispatcherOnly)
+        {
+            return;
+        }
         List<SysJob> jobList = jobMapper.selectJobAll();
         for (SysJob job : jobList)
         {

+ 35 - 0
fs-quartz/src/main/java/com/fs/quartz/util/CronUtils.java

@@ -1,6 +1,7 @@
 package com.fs.quartz.util;
 
 import java.text.ParseException;
+import java.util.Calendar;
 import java.util.Date;
 import org.quartz.CronExpression;
 
@@ -60,4 +61,38 @@ public class CronUtils
             throw new IllegalArgumentException(e.getMessage());
         }
     }
+
+    /**
+     * 判断 cron 表达式是否在当前这一分钟内会触发(用于 SaaS 租户任务分发器)
+     *
+     * @param cronExpression cron 表达式
+     * @return 当前分钟内会触发返回 true
+     */
+    public static boolean isDueInThisMinute(String cronExpression)
+    {
+        if (cronExpression == null || cronExpression.isEmpty())
+        {
+            return false;
+        }
+        try
+        {
+            CronExpression cron = new CronExpression(cronExpression);
+            Calendar cal = Calendar.getInstance();
+            cal.set(Calendar.SECOND, 0);
+            cal.set(Calendar.MILLISECOND, 0);
+            Date startOfMinute = cal.getTime();
+            Date next = cron.getNextValidTimeAfter(startOfMinute);
+            if (next == null)
+            {
+                return false;
+            }
+            cal.add(Calendar.MINUTE, 1);
+            Date endOfMinute = cal.getTime();
+            return !next.before(startOfMinute) && next.before(endOfMinute);
+        }
+        catch (ParseException e)
+        {
+            return false;
+        }
+    }
 }

+ 85 - 0
fs-qw-task/src/main/java/com/fs/app/task/TenantTaskRunner.java

@@ -0,0 +1,85 @@
+package com.fs.app.task;
+
+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.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.function.Consumer;
+import java.util.stream.Collectors;
+
+/**
+ * SaaS 模式下按租户执行定时任务:从主库查启用租户,逐租户切库并设置租户配置后执行传入的逻辑。
+ */
+@Slf4j
+@Component
+public class TenantTaskRunner {
+
+    @Resource
+    private TenantDataSourceManager tenantDataSourceManager;
+    @Resource
+    private TenantInfoService tenantInfoService;
+    @Resource
+    private SysConfigMapper sysConfigMapper;
+
+    /**
+     * 对每个启用且未过期的租户执行一次 action(已切到该租户库并设置 TenantConfigContext)。
+     */
+    public void runForEachTenant(Consumer<TenantInfo> action) {
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            TenantInfo query = new TenantInfo();
+            query.setStatus(1);
+            List<TenantInfo> tenants = tenantInfoService.selectTenantInfoList(query);
+            if (tenants == null || tenants.isEmpty()) {
+                log.debug("[SaaS Task] 无启用租户,跳过");
+                return;
+            }
+            Date now = new Date();
+            List<TenantInfo> validTenants = tenants.stream()
+                    .filter(t -> t.getExpireTime() == null || !t.getExpireTime().before(now))
+                    .collect(Collectors.toList());
+
+            for (TenantInfo tenant : validTenants) {
+                try {
+                    tenantDataSourceManager.switchTenant(tenant);
+                    SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+                    if (cfg != null && StringUtils.isNotBlank(cfg.getConfigValue())) {
+                        TenantConfigContext.set(JSONObject.parseObject(cfg.getConfigValue()));
+                    } else {
+                        TenantConfigContext.set(null);
+                    }
+                    ProjectConfig.loadTenantConfigsFromContext();
+                    action.accept(tenant);
+                } catch (Exception e) {
+                    log.error("[SaaS Task] 租户 tenantId={}, tenantCode={} 执行异常", tenant.getId(), tenant.getTenantCode(), e);
+                } finally {
+                    ProjectConfig.clearTenantConfigs();
+                    TenantConfigContext.clear();
+                    DynamicDataSourceContextHolder.clearDataSourceType();
+                }
+            }
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    /**
+     * 对每个租户执行无参逻辑(不需要 TenantInfo 时使用)。
+     */
+    public void runForEachTenant(Runnable action) {
+        runForEachTenant(t -> action.run());
+    }
+}

+ 137 - 68
fs-qw-task/src/main/java/com/fs/app/task/qwTask.java

@@ -18,6 +18,7 @@ import com.fs.sop.service.impl.QwSopServiceImpl;
 import com.fs.sop.vo.QwSopLogsDoSendListTVO;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.scheduling.annotation.Scheduled;
 import org.springframework.stereotype.Component;
@@ -108,6 +109,13 @@ public class qwTask {
     @Autowired
     private SyncQwExternalContactService syncQwExternalContactService;
 
+    @Autowired
+    private TenantTaskRunner tenantTaskRunner;
+
+    /** SaaS 模式:为 true 时各定时任务按租户遍历执行;为 false 时保持原单库执行(私有化部署) */
+    @Value("${saas.task.enabled:false}")
+    private boolean saasTaskEnabled;
+
     /**
      * 定时任务:检查SOP规则时间
      * 执行时间:每天凌晨 1:10:00
@@ -115,7 +123,11 @@ public class qwTask {
      */
     @Scheduled(cron = "0 10 1 * * ?")
     public void qwCheckSopRuleTime() {
-        qwSopService.checkSopRuleTime();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopService.checkSopRuleTime());
+        } else {
+            qwSopService.checkSopRuleTime();
+        }
     }
 
     /**
@@ -125,7 +137,11 @@ public class qwTask {
      */
     @Scheduled(cron = "0 0/20 * * * ?")
     public void addTag() {
-        qwSopTagService.addTag();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopTagService.addTag());
+        } else {
+            qwSopTagService.addTag();
+        }
     }
 
     /**
@@ -138,13 +154,19 @@ public class qwTask {
     @Scheduled(cron = "0 5 * * * ?") // 每小时的第5分钟触发
     @Async
     public void selectSopUserLogsListByTime() throws Exception {
-        // 获取当前时间,精确到小时
         LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
-        // 打印日志,确认任务执行时间
         log.info("任务实际执行时间: {}", currentTime);
-
-        // 调用服务方法处理SOP用户日志
-        sopLogsTaskService.selectSopUserLogsListByTime(currentTime, null);
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> {
+                try {
+                    sopLogsTaskService.selectSopUserLogsListByTime(currentTime, null);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+            });
+        } else {
+            sopLogsTaskService.selectSopUserLogsListByTime(currentTime, null);
+        }
     }
 
     /**
@@ -156,13 +178,19 @@ public class qwTask {
      */
     @Scheduled(cron = "0 5 * * * ?") // 每小时的第5分钟触发
     public void wxSop() throws Exception {
-        // 获取当前时间,精确到小时
         LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
-        // 打印日志,确认任务执行时间
         log.info("任务实际执行时间: {}", currentTime);
-
-        // 调用服务方法处理微信SOP日志
-        sopWxLogsService.wxSopLogsByTime(currentTime);
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> {
+                try {
+                    sopWxLogsService.wxSopLogsByTime(currentTime);
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+            });
+        } else {
+            sopWxLogsService.wxSopLogsByTime(currentTime);
+        }
     }
 
     /**
@@ -187,11 +215,13 @@ public class qwTask {
     @Scheduled(cron = "0 20 1 * * ?")
     public void SendQwApiSopLogTimer(){
         log.info("zyp \n【企微官方接口群发开始-单链】");
-//        qwSopLogsService.checkQwSopLogs();
         LocalDate localDate = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0).toLocalDate();
         String date = localDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
-
-        qwSopLogsService.createCorpMassSending(date);
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopLogsService.createCorpMassSending(date));
+        } else {
+            qwSopLogsService.createCorpMassSending(date);
+        }
     }
 
     /**
@@ -199,29 +229,30 @@ public class qwTask {
      */
     @Scheduled(cron = "0 10 0,1 * * ?")
     public void SendQwApiSopLogTimerNew(){
-
         log.info("zyp \n【企微官方接口群发开始】");
-//        qwSopLogsService.checkQwSopLogs();
-//        LocalDate localDate = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0).toLocalDate();
-//        String date = localDate.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
-
         int currentHour = LocalDateTime.now().getHour();
         String taskStartTime = LocalDate.now().atTime(currentHour, 0, 0)
                 .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
         String taskEndTime = LocalDate.now().atTime(currentHour, 59, 59)
                 .format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
-
-        qwSopLogsService.createCorpMassSendingByUserLogs(taskStartTime,taskEndTime);
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopLogsService.createCorpMassSendingByUserLogs(taskStartTime, taskEndTime));
+        } else {
+            qwSopLogsService.createCorpMassSendingByUserLogs(taskStartTime, taskEndTime);
+        }
     }
 
     /**
-     *
      * 执行时间:每天上午 8:00:00
      * 功能:获取通过企业微信接口发送的SOP客户群发消息的反馈结果
      */
     @Scheduled(cron = "0 0 8 * * ?")
     public void GetQwApiSopLogResultTimerNew() {
-        qwSopLogsService.qwSopLogsResultNew();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopLogsService.qwSopLogsResultNew());
+        } else {
+            qwSopLogsService.qwSopLogsResultNew();
+        }
     }
 
     /**
@@ -231,7 +262,11 @@ public class qwTask {
      */
     @Scheduled(cron = "0 0/10 * * * ?")
     public void sendQwGroupMsgTask() {
-        qwGroupMsgService.qwGroupMsgTask();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwGroupMsgService.qwGroupMsgTask());
+        } else {
+            qwGroupMsgService.qwGroupMsgTask();
+        }
     }
 
     /**
@@ -240,9 +275,12 @@ public class qwTask {
      * 功能:根据SOP规则发送转换消息
      */
     @Scheduled(cron = "0 0 8 * * ?")
-//    @Scheduled(cron = "0/10 * * * * ?") // 测试用:每10秒执行一次
     public void sendQwBySop() {
-        sopUserLogsService.sendQwBySop();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> sopUserLogsService.sendQwBySop());
+        } else {
+            sopUserLogsService.sendQwBySop();
+        }
     }
 
     /**
@@ -253,7 +291,11 @@ public class qwTask {
     @Scheduled(cron = "0 0/3 * * * ?")
     public void qwExternalErrRetryTimer() {
         log.info("补偿机制开始");
-        errRetryService.qwExternalErrRetryTimer();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> errRetryService.qwExternalErrRetryTimer());
+        } else {
+            errRetryService.qwExternalErrRetryTimer();
+        }
     }
 
     /**
@@ -261,14 +303,24 @@ public class qwTask {
      * 执行时间:每小时的第0分钟执行
      * 功能:补发已过期但未发送的完课消息
      */
-    @Scheduled(cron = "0 0 * * * ?")  // 每小时的第0分钟0秒执行
+    @Scheduled(cron = "0 0 * * * ?")
     public void updateQwSopLogsByCancel() {
         log.info("补发过期完课消息 - 定时任务开始");
-        try {
-            sopLogsTaskService.updateSopLogsByCancel();
-            log.info("补发过期完课消息 - 定时任务成功完成");
-        } catch (Exception e) {
-            log.error("补发过期完课消息 - 定时任务执行失败", e);
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> {
+                try {
+                    sopLogsTaskService.updateSopLogsByCancel();
+                } catch (Exception e) {
+                    throw new RuntimeException(e);
+                }
+            });
+        } else {
+            try {
+                sopLogsTaskService.updateSopLogsByCancel();
+                log.info("补发过期完课消息 - 定时任务成功完成");
+            } catch (Exception e) {
+                log.error("补发过期完课消息 - 定时任务执行失败", e);
+            }
         }
     }
 
@@ -280,11 +332,17 @@ public class qwTask {
     @Scheduled(cron = "0 0/8 * * * ?")
     public void batchProcessingExpiredMessages() {
         log.info("批量处理sop待发送记录中已过期的消息");
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(this::doBatchProcessingExpiredMessages);
+        } else {
+            doBatchProcessingExpiredMessages();
+        }
+    }
+
+    private void doBatchProcessingExpiredMessages() {
         try {
-            // 步骤1:批量获取已过期的记录
             List<QwSopLogsDoSendListTVO> expireded = iQwSopLogsService.expiredMessagesByQwSopLogs();
             if (!expireded.isEmpty()) {
-                // 步骤2:批量处理并插入记录
                 processAndInsertQwSopLogs(expireded);
             }
             log.info("处理已过期 - 定时任务成功完成");
@@ -326,7 +384,11 @@ public class qwTask {
      */
     @Scheduled(cron = "0 10 0 * * ?")
     public void deleteQwSopLogsByDate() {
-        qwSopLogsMapper.deleteQwSopLogsByDate();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopLogsMapper.deleteQwSopLogsByDate());
+        } else {
+            qwSopLogsMapper.deleteQwSopLogsByDate();
+        }
     }
 
     /**
@@ -336,10 +398,13 @@ public class qwTask {
      */
     @Scheduled(cron = "0 30 0/3 * * ? ")
     public void processRepairQwSopLogsTimer() {
-        sopUserLogsService.repairSopUserLogsTimer();
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> sopUserLogsService.repairSopUserLogsTimer());
+        } else {
+            sopUserLogsService.repairSopUserLogsTimer();
+        }
     }
 
-
     /**
      * 凌晨 2点35开始,将营期小于7天中标记为 是否7天未看课的(E级) 客户的 但是看课了的恢复一下
      */
@@ -348,9 +413,11 @@ public class qwTask {
     public void processSopUserLogsInfoByIsDaysNotStudy() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 是否7天未看课的(E级) 客户的 恢复一下 ======");
-
-        logsInfoByIsDaysNotStudy.restoreByIsDaysNotStudy();
-
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> logsInfoByIsDaysNotStudy.restoreByIsDaysNotStudy());
+        } else {
+            logsInfoByIsDaysNotStudy.restoreByIsDaysNotStudy();
+        }
         long endTimeMillis = System.currentTimeMillis();
         log.info("====== 用户E级恢复处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
     }
@@ -359,19 +426,17 @@ public class qwTask {
      * 定时任务:客户评级处理
      * 执行时间:每天凌晨 3:45:00
      * 功能:对SOP营期用户进行分级评级
-     * 备注:异步执行,避免阻塞其他任务
      */
     @Scheduled(cron = "0 45 3 * * ?")
     @Async
     public void processQwSopExternalContactRatingTimer() {
-        // 记录任务开始时间
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 sop营期-用户分级 ======");
-
-        // 执行用户分级评级
-        qwExternalContactRatingService.ratingUserLogs();
-
-        // 计算并记录任务执行耗时
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwExternalContactRatingService.ratingUserLogs());
+        } else {
+            qwExternalContactRatingService.ratingUserLogs();
+        }
         long endTimeMillis = System.currentTimeMillis();
         log.info("====== sop营期-用户分级处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
     }
@@ -384,14 +449,15 @@ public class qwTask {
     public void processQwSopExternalContactRatingMoreSevenDaysTimer() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 sop营期-用户超7天的看课情况 ======");
-
-        qwExternalContactRatingMoreSevenDaysService.ratingMoreSevenDaysUserLogs();
-
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwExternalContactRatingMoreSevenDaysService.ratingMoreSevenDaysUserLogs());
+        } else {
+            qwExternalContactRatingMoreSevenDaysService.ratingMoreSevenDaysUserLogs();
+        }
         long endTimeMillis = System.currentTimeMillis();
         log.info("====== sop营期-用户超7天处理完成,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
     }
 
-
     /**
      * 更新掉所有前一天的所有待发送
      */
@@ -399,8 +465,11 @@ public class qwTask {
     public void updateQwSopLogsDayBefore() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 更新掉所有前一天的所有待发送 ======");
-        qwSopLogsMapper.updateQwSopLogsByDayBefore();
-
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> qwSopLogsMapper.updateQwSopLogsByDayBefore());
+        } else {
+            qwSopLogsMapper.updateQwSopLogsByDayBefore();
+        }
         long endTimeMillis = System.currentTimeMillis();
         log.info("====== 更新掉所有前一天的所有待发送,耗时 {} 毫秒 ======", (endTimeMillis - startTimeMillis));
     }
@@ -409,8 +478,11 @@ public class qwTask {
     public void updateQwExternalContactUnionid() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 同步外部联系人的UnionId ======");
-        syncQwExternalContactService.syncQwExternalContactUnionid();
-
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(() -> syncQwExternalContactService.syncQwExternalContactUnionid());
+        } else {
+            syncQwExternalContactService.syncQwExternalContactUnionid();
+        }
     }
 
     /**
@@ -418,28 +490,26 @@ public class qwTask {
      */
     @Scheduled(cron = "0 0 16 * * ?")
     public void autoPullGroup(){
-        //  拉群 ,①保持群号 ②每日拉群 ③创建建群记录
-        // 计算每个人最大拉人数量
+        if (saasTaskEnabled) {
+            tenantTaskRunner.runForEachTenant(this::doAutoPullGroup);
+        } else {
+            doAutoPullGroup();
+        }
+    }
+
+    private void doAutoPullGroup() {
         long maxNum = (long) MAX_GROUP_NUM * MAX_GROUP_USER_NUM;
-        // 获取当前时间
         LocalDate now = LocalDate.now();
-        // 获取需要自动拉群的SOP任务
         List<QwSop> list = qwSopMapper.selectGroup(now);
         if(list == null || list.isEmpty()) return;
         list.forEach(sop -> {
-            // 获取这个SOP下面的企微ID
             List<Long> qwUserIdList = Arrays.stream(sop.getQwUserIds().split(",")).map(Long::parseLong).distinct().collect(Collectors.toList());
-            // 获取企微ID下面的所有用户
             List<QwExternalContact> qwExternalContactList = qwExternalContactService.selectQwUserAndLevel(qwUserIdList, Arrays.asList(sop.getAutoGroupLevel().split(",")), sop.getAutoUserReg() == 1);
-            // 根据企微ID进行分组
             Map<Long, List<QwExternalContact>> qwUserMap = PubFun.listToMapByGroupList(qwExternalContactList, QwExternalContact::getQwUserId);
-            // 获取企微列表
             List<QwUser> qwUserList = qwUserService.selectQwUserByIds(qwUserIdList);
             try {
-                // 每个企微都拉人
                 qwUserList.stream().filter(qwUser -> qwUserMap.containsKey(qwUser.getId())).forEach(qwUser -> {
                     List<QwExternalContact> userList = qwUserMap.get(qwUser.getId()).stream().limit(maxNum).collect(Collectors.toList());
-                    // 创建群 如果没人或者人数没达到满群的要求,不进行建群
                     if(userList.isEmpty() || userList.size() < MAX_GROUP_USER_NUM) return;
                     List<QwGroupChat> chatList = qwGroupChatService.selectSopAndQwUser(qwUser.getQwUserId(), sop.getId());
                     int groupNum = 0;
@@ -447,7 +517,6 @@ public class qwTask {
                         groupNum = extractLastNumber(chatList.get(0).getName())  == null ? 0 : extractLastNumber(chatList.get(0).getName());
                     }
                     try {
-                        // 建群
                         ipadSendUtils.createRoom(sop, sop.getGroupName(), qwUser, userList, MAX_GROUP_NUM, MAX_GROUP_USER_NUM,groupNum);
                     }catch (Exception e){
                         log.error("群聊拉人进群错误:{},企微ID:{},企微名称:{},外部联系人:{}", e.getMessage(), qwUser.getId(), qwUser.getQwUserName(), PubFun.listToNewList(userList, QwExternalContact::getId));

+ 1 - 0
fs-qw-task/src/main/java/com/fs/framework/config/DataSourceConfig.java

@@ -40,6 +40,7 @@ public class DataSourceConfig {
     @Primary
     public DynamicDataSource dataSource(@Qualifier("masterDataSource") DataSource masterDataSource, @Qualifier("sopDataSource") DataSource sopDataSource) {
         Map<Object, Object> targetDataSources = new HashMap<>();
+        targetDataSources.put(DataSourceType.MASTER.name(), masterDataSource);
         targetDataSources.put(DataSourceType.SOP.name(), sopDataSource);
         return new DynamicDataSource(masterDataSource, targetDataSources);
     }

+ 71 - 0
fs-qw-task/src/main/java/com/fs/framework/datasource/TenantDataSourceManager.java

@@ -0,0 +1,71 @@
+package com.fs.framework.datasource;
+
+import com.alibaba.druid.pool.DruidDataSource;
+import com.fs.tenant.domain.TenantInfo;
+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;
+
+/**
+ * 租户数据源管理,SaaS 模式下定时任务按租户切库时使用。
+ */
+@Component
+public class TenantDataSourceManager {
+
+    @Resource
+    private DynamicDataSource dynamicDataSource;
+
+    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();
+    }
+
+    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;
+    }
+
+    @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);
+        }
+    }
+}

+ 5 - 0
fs-qw-task/src/main/resources/application.yml

@@ -11,3 +11,8 @@ spring:
 #    active: druid-sxjz
 #    active: druid-hdt
     active: druid-myhk-test
+
+# SaaS 模式:为 true 时各 @Scheduled 定时任务按租户遍历执行(主库需有 tenant_info,各租户独立库)
+# saas:
+#   task:
+#     enabled: true