SaaS改造方案.md 23 KB

互联网医院平台 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 + DynamicDataSourcefs-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-commonfs-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 的依赖(若尚未引入),以便使用 JwtAuthenticationTokenFilterDynamicDataSourceTenantDataSourceManagerTenantConfigContext
  • 数据源:改为使用 fs-frameworkDataSourceConfig(即主库 + 可选 SOP/从库 + 租户动态数据源),或在本模块复制一份并保证 DynamicDataSource 注入 TenantDataSourceManager 使用的同一实例,这样 TenantDataSourceManager 动态加入的租户库才会生效。

4.2.2 认证与 Token 统一

  • Token 生成:C 端登录(微信/手机号/账号密码等)时,若需多租户,则:
    • 入参增加 tenantCode(或从请求头 X-Tenant-Code 取);
    • 查主库 tenant_info 校验租户状态;
    • 调用 TenantDataSourceManager.switchTenant(tenantInfo) 切换到该租户库(便于本次登录逻辑里查该租户下的用户等);
    • 构建 LoginUsersetTenantId(tenantInfo.getId())
    • 使用 fs-frameworkTokenService.createToken(loginUser) 生成 JWT,这样 JWT 中会带 tenantId。
  • 请求头:为与现有前端兼容,可保留 APPToken 作为 Token 传递方式;在 fs-frameworkTokenService.getLoginUser(request) 中支持从 APPToken 解析(或 user-app 单独一个 Filter 先取 APPToken 再塞到 Authorization),保证后续 Filter 拿到的 LoginUser 含 tenantId。

4.2.3 启用 framework 的 JWT + 租户过滤器

  • SecurityConfig:在 fs-user-appSecurityConfig 中,加入与 fs-admin 相同的 JwtAuthenticationTokenFilter(并注入 TokenServiceDynamicDataSourceSysConfigMapper),保证每个请求:
    • 解析 Token 得到 LoginUser(含 tenantId);
    • 按 tenantId 设置 DynamicDataSourceContextHolderTenantConfigContext
    • 在 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 参数,而是统一从 TenantConfigContextDynamicDataSourceContextHolder 间接使用(当前库即租户库);若业务要写 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,设置到 DynamicDataSourceContextHolderTenantConfigContext,不涉及 JWT 用户身份。

4.4 fs-framework 可做的增强(可选)

增强项 说明
租户解析器 提供 TenantResolver 接口:从 HttpServletRequest(域名/请求头/参数)解析出 tenantCodetenantId,查主库得到 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 JwtAuthenticationTokenFilterTenantDataSourceManagerDataSourceConfigTokenService;新增 TenantResolver(可选)
fs-admin 已支持多租户,仅需保证登录/Token 与文档一致,无需大改
fs-user-app pom.xmlDataSourceConfig/数据源、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. 执行完毕后清理 DynamicDataSourceContextHolderTenantConfigContext
  • 优点:各租户的 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 中清理 DynamicDataSourceContextHolderTenantConfigContext
    • 若某租户执行抛错,可记录日志并继续下一个租户,避免一个租户异常导致其余不执行。
  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(原有逻辑)。