Преглед изворни кода

1、调整定时任务分配执行,停用后暂停定时任务,根据定时任务子对象进行执行任务

yys пре 1 дан
родитељ
комит
328b7962e8
34 измењених фајлова са 1201 додато и 489 уклоњено
  1. 8 0
      fs-admin/src/main/java/com/fs/admin/controller/monitor/TenantJobController.java
  2. 41 0
      fs-common/src/main/java/com/fs/common/constant/ScheduleConstants.java
  3. 1 1
      fs-service/src/main/java/com/fs/his/service/impl/SysRedpacketConfigMoreServiceImpl.java
  4. 17 0
      fs-service/src/main/java/com/fs/quartz/dto/ScheduleJobSyncMessage.java
  5. 11 0
      fs-service/src/main/java/com/fs/quartz/mapper/SysJobTemplateMapper.java
  6. 15 0
      fs-service/src/main/java/com/fs/quartz/mapper/TenantJobConfigMapper.java
  7. 5 0
      fs-service/src/main/java/com/fs/quartz/service/ITenantJobConfigService.java
  8. 83 164
      fs-service/src/main/java/com/fs/quartz/service/impl/SysJobServiceImpl.java
  9. 1 0
      fs-service/src/main/java/com/fs/quartz/service/impl/SysJobTemplateServiceImpl.java
  10. 47 2
      fs-service/src/main/java/com/fs/quartz/service/impl/TenantJobConfigServiceImpl.java
  11. 54 0
      fs-service/src/main/java/com/fs/quartz/support/ScheduleJobSyncPublisher.java
  12. 17 0
      fs-service/src/main/resources/mapper/quartz/SysJobTemplateMapper.xml
  13. 40 0
      fs-service/src/main/resources/mapper/quartz/TenantJobConfigMapper.xml
  14. 3 0
      fs-task/src/main/java/com/fs/FsTaskApplication.java
  15. 10 21
      fs-task/src/main/java/com/fs/quartz/config/ScheduleConfig.java
  16. 39 0
      fs-task/src/main/java/com/fs/quartz/config/ScheduleJobRedisConfig.java
  17. 3 53
      fs-task/src/main/java/com/fs/quartz/service/SysJobInitService.java
  18. 171 0
      fs-task/src/main/java/com/fs/quartz/service/SysJobSchedulerService.java
  19. 66 0
      fs-task/src/main/java/com/fs/quartz/support/ScheduleJobSyncSubscriber.java
  20. 73 0
      fs-task/src/main/java/com/fs/quartz/support/TenantTaskContextHelper.java
  21. 8 3
      fs-task/src/main/java/com/fs/quartz/task/RyTask.java
  22. 75 56
      fs-task/src/main/java/com/fs/quartz/util/AbstractQuartzJob.java
  23. 57 20
      fs-task/src/main/java/com/fs/quartz/util/JobInvokeUtil.java
  24. 233 0
      fs-task/src/main/java/com/fs/quartz/util/MultiScopeJobDispatcher.java
  25. 10 9
      fs-task/src/main/java/com/fs/quartz/util/QuartzDisallowConcurrentExecution.java
  26. 9 13
      fs-task/src/main/java/com/fs/quartz/util/QuartzJobExecution.java
  27. 3 5
      fs-task/src/main/java/com/fs/quartz/util/ScheduleUtils.java
  28. 16 0
      fs-task/src/main/java/com/fs/quartz/util/ScopedJobExecutor.java
  29. 0 114
      fs-task/src/main/java/com/fs/quartz/util/TenantJobDispatcherJob.java
  30. 3 8
      fs-task/src/main/java/com/fs/task/jobs/QwTask.java
  31. 35 7
      fs-task/src/main/java/com/fs/task/support/impl/SopLogsChatTaskServiceImpl.java
  32. 39 13
      fs-task/src/main/java/com/fs/task/support/impl/SopLogsTaskServiceImpl.java
  33. 5 0
      fs-task/src/main/resources/application-dev.yml
  34. 3 0
      fs-task/src/main/resources/logback.xml

+ 8 - 0
fs-admin/src/main/java/com/fs/admin/controller/monitor/TenantJobController.java

@@ -96,6 +96,14 @@ public class TenantJobController extends BaseController {
         return AjaxResult.success();
     }
 
+    /** 按模板全量覆盖租户分配:仅保留 tenantIds 中的租户 */
+    @PreAuthorize("@ss.hasPermi('monitor:job:edit')")
+    @PutMapping("/template/{templateId}/tenants")
+    public AjaxResult assignTemplateTenants(@PathVariable Long templateId, @RequestBody List<Long> tenantIds) {
+        tenantJobConfigService.assignTemplateToTenants(templateId, tenantIds, getUsername());
+        return AjaxResult.success();
+    }
+
     /** 同步全部模板到指定租户库(自动创建缺失的 config) */
     @PreAuthorize("@ss.hasPermi('monitor:job:edit')")
     @PostMapping("/sync/{tenantId}")

+ 41 - 0
fs-common/src/main/java/com/fs/common/constant/ScheduleConstants.java

@@ -12,6 +12,20 @@ public class ScheduleConstants
     /** 执行目标key */
     public static final String TASK_PROPERTIES = "TASK_PROPERTIES";
 
+    /** 定时任务变更 Redis 发布订阅频道(fs-admin 写库后发布,fs-task 订阅并刷新 Quartz) */
+    public static final String REDIS_CHANNEL_JOB_SYNC = "schedule:job:sync";
+
+    /** 全量重载 sys_job 到 Quartz */
+    public static final String JOB_SYNC_RELOAD_ALL = "RELOAD_ALL";
+    /** 新增/修改/恢复后刷新单个任务 */
+    public static final String JOB_SYNC_UPSERT = "UPSERT";
+    /** 暂停任务 */
+    public static final String JOB_SYNC_PAUSE = "PAUSE";
+    /** 删除任务 */
+    public static final String JOB_SYNC_DELETE = "DELETE";
+    /** 立即触发执行 */
+    public static final String JOB_SYNC_RUN = "RUN";
+
     /** 默认 */
     public static final String MISFIRE_DEFAULT = "0";
 
@@ -47,4 +61,31 @@ public class ScheduleConstants
             return value;
         }
     }
+
+    /**
+     * 任务范围枚举(PLATFORM=平台级 TENANT=租户级)
+     */
+    public enum JobScope
+    {
+        /**
+         * 平台级任务:在主库执行
+         */
+        PLATFORM("PLATFORM"),
+        /**
+         * 租户级任务:查询配置的租户并行执行
+         */
+        TENANT("TENANT");
+
+        private String value;
+
+        private JobScope(String value)
+        {
+            this.value = value;
+        }
+
+        public String getValue()
+        {
+            return value;
+        }
+    }
 }

+ 1 - 1
fs-service/src/main/java/com/fs/his/service/impl/SysRedpacketConfigMoreServiceImpl.java

@@ -102,7 +102,7 @@ public class SysRedpacketConfigMoreServiceImpl extends ServiceImpl<SysRedpacketC
     @Override
     public void changeRedPacketConfig() {
         Integer count = redisCache.getCacheObject("sys_config:redPacket.config.newCount");
-        if (count >= 1000) {
+        if (count != null && count >= 1000) {
             String json = redisCache.getCacheObject("sys_config:redPacket.config.new");
             if (StringUtil.isNullOrEmpty(json) || json.isEmpty()) {
                 json = configService.selectConfigByKey("redPacket.config");

+ 17 - 0
fs-service/src/main/java/com/fs/quartz/dto/ScheduleJobSyncMessage.java

@@ -0,0 +1,17 @@
+package com.fs.quartz.dto;
+
+import lombok.Data;
+
+/**
+ * 定时任务变更同步消息(Redis pub/sub)。
+ */
+@Data
+public class ScheduleJobSyncMessage {
+
+    /** {@link com.fs.common.constant.ScheduleConstants} 中的 JOB_SYNC_* */
+    private String action;
+
+    private Long jobId;
+
+    private String jobGroup;
+}

+ 11 - 0
fs-service/src/main/java/com/fs/quartz/mapper/SysJobTemplateMapper.java

@@ -20,4 +20,15 @@ public interface SysJobTemplateMapper {
     int deleteTemplateById(Long templateId);
 
     List<SysJobTemplate> selectTenantScopeTemplates();
+
+    /**
+     * 根据 jobGroup 和 invokeTarget 查询模板 scope
+     */
+    String selectScopeByJob(@Param("jobGroup") String jobGroup, @Param("invokeTarget") String invokeTarget);
+
+    /** 根据模板 ID 查询 scope(sys_job.job_id = template_id) */
+    String selectScopeByTemplateId(@Param("templateId") Long templateId);
+
+    /** 根据 jobGroup+invokeTarget 解析唯一模板 ID(同 invokeTarget 多模板时仅取一条) */
+    Long selectTemplateIdByJob(@Param("jobGroup") String jobGroup, @Param("invokeTarget") String invokeTarget);
 }

+ 15 - 0
fs-service/src/main/java/com/fs/quartz/mapper/TenantJobConfigMapper.java

@@ -13,6 +13,21 @@ public interface TenantJobConfigMapper {
 
     List<TenantJobConfig> selectEnabledForSync(@Param("tenantId") Long tenantId);
 
+    /**
+     * 根据模板 jobGroup + invokeTarget 查询已分配且启用的租户(用于租户级任务执行)
+     */
+    List<TenantJobConfig> selectTenantsByTemplate(@Param("jobGroup") String jobGroup,
+                                                  @Param("invokeTarget") String invokeTarget);
+
+    /**
+     * 根据模板 ID 精确查询已分配且启用的租户(sys_job.job_id = template_id)
+     */
+    List<TenantJobConfig> selectTenantsByTemplateId(@Param("templateId") Long templateId);
+
+    /** 删除某模板下不在指定租户列表中的分配记录 */
+    int deleteByTemplateAndNotIn(@Param("templateId") Long templateId,
+                                 @Param("tenantIds") List<Long> tenantIds);
+
     TenantJobConfig selectByTenantAndTemplate(@Param("tenantId") Long tenantId,
                                               @Param("templateId") Long templateId);
 

+ 5 - 0
fs-service/src/main/java/com/fs/quartz/service/ITenantJobConfigService.java

@@ -14,6 +14,11 @@ public interface ITenantJobConfigService {
 
     void saveTenantConfig(TenantJobConfigSaveDTO dto, String operator);
 
+    /**
+     * 按模板全量覆盖租户分配:仅保留 tenantIds 中的租户,其余删除。
+     */
+    void assignTemplateToTenants(Long templateId, List<Long> tenantIds, String operator);
+
     void updateStatus(Long configId, String status);
 
     Map<String, Object> syncTenant(Long tenantId);

+ 83 - 164
fs-service/src/main/java/com/fs/quartz/service/impl/SysJobServiceImpl.java

@@ -5,236 +5,155 @@ import com.fs.common.utils.CronUtils;
 import com.fs.quartz.domain.SysJob;
 import com.fs.quartz.mapper.SysJobMapper;
 import com.fs.quartz.service.ISysJobService;
+import com.fs.quartz.support.ScheduleJobSyncPublisher;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
 import java.util.List;
 
 /**
- * 定时任务调度信息 服务层
- *
-
+ * 定时任务调度信息 服务层(写库后通过 Redis 通知 fs-task 刷新 Quartz)。
  */
 @Service
-public class SysJobServiceImpl implements ISysJobService
-{
-//    @Autowired
-//    private Scheduler scheduler;
+public class SysJobServiceImpl implements ISysJobService {
 
     @Autowired
     private SysJobMapper jobMapper;
 
-//    /**
-//     * 项目启动时,初始化定时器 主要是防止手动修改数据库导致未同步到定时任务处理(注:不能手动修改数据库ID和任务组名,否则会导致脏数据)
-//     * SaaS 模式下仅清空调度器,具体任务由租户分发器按租户执行。
-//     */
-//    @PostConstruct
-//    public void init() throws SchedulerException, TaskException
-//    {
-//        scheduler.clear();
-//        List<SysJob> jobList = jobMapper.selectJobAll();
-//        for (SysJob job : jobList)
-//        {
-//            ScheduleUtils.createScheduleJob(scheduler, job);
-//        }
-//    }
-
-    /**
-     * 获取quartz调度器的计划任务列表
-     *
-     * @param job 调度信息
-     * @return
-     */
+    @Autowired(required = false)
+    private ScheduleJobSyncPublisher scheduleJobSyncPublisher;
+
     @Override
-    public List<SysJob> selectJobList(SysJob job)
-    {
+    public List<SysJob> selectJobList(SysJob job) {
         return jobMapper.selectJobList(job);
     }
 
-    /**
-     * 通过调度任务ID查询调度信息
-     *
-     * @param jobId 调度任务ID
-     * @return 调度任务对象信息
-     */
     @Override
-    public SysJob selectJobById(Long jobId)
-    {
+    public SysJob selectJobById(Long jobId) {
         return jobMapper.selectJobById(jobId);
     }
 
-    /**
-     * 暂停任务
-     *
-     * @param job 调度信息
-     */
     @Override
-    public int pauseJob(SysJob job)
-    {
-        Long jobId = job.getJobId();
-        String jobGroup = job.getJobGroup();
+    public int pauseJob(SysJob job) {
         job.setStatus(ScheduleConstants.Status.PAUSE.getValue());
         int rows = jobMapper.updateJob(job);
-        if (rows > 0)
-        {
-//            scheduler.pauseJob(ScheduleUtils.getJobKey(jobId, jobGroup));
+        if (rows > 0) {
+            notifyPause(job.getJobId(), job.getJobGroup());
         }
         return rows;
     }
 
-    /**
-     * 恢复任务
-     *
-     * @param job 调度信息
-     */
     @Override
-    public int resumeJob(SysJob job)
-    {
-        Long jobId = job.getJobId();
-        String jobGroup = job.getJobGroup();
+    public int resumeJob(SysJob job) {
         job.setStatus(ScheduleConstants.Status.NORMAL.getValue());
         int rows = jobMapper.updateJob(job);
-        if (rows > 0)
-        {
-//            scheduler.resumeJob(ScheduleUtils.getJobKey(jobId, jobGroup));
+        if (rows > 0) {
+            notifyUpsert(job.getJobId(), job.getJobGroup());
         }
         return rows;
     }
 
-    /**
-     * 删除任务后,所对应的trigger也将被删除
-     *
-     * @param job 调度信息
-     */
     @Override
-    public int deleteJob(SysJob job)
-    {
-        Long jobId = job.getJobId();
-        String jobGroup = job.getJobGroup();
-        int rows = jobMapper.deleteJobById(jobId);
-        if (rows > 0)
-        {
-//            scheduler.deleteJob(ScheduleUtils.getJobKey(jobId, jobGroup));
+    public int deleteJob(SysJob job) {
+        int rows = jobMapper.deleteJobById(job.getJobId());
+        if (rows > 0) {
+            notifyDelete(job.getJobId(), job.getJobGroup());
         }
         return rows;
     }
 
-    /**
-     * 批量删除调度信息
-     *
-     * @param jobIds 需要删除的任务ID
-     * @return 结果
-     */
     @Override
-    public void deleteJobByIds(Long[] jobIds)
-    {
-        for (Long jobId : jobIds)
-        {
+    public void deleteJobByIds(Long[] jobIds) {
+        for (Long jobId : jobIds) {
             SysJob job = jobMapper.selectJobById(jobId);
-//            deleteJob(job);
+            if (job != null) {
+                deleteJob(job);
+            }
         }
     }
 
-    /**
-     * 任务调度状态修改
-     *
-     * @param job 调度信息
-     */
     @Override
-    public int changeStatus(SysJob job)
-    {
-        int rows = 0;
+    public int changeStatus(SysJob job) {
         String status = job.getStatus();
-        if (ScheduleConstants.Status.NORMAL.getValue().equals(status))
-        {
-            rows = resumeJob(job);
+        if (ScheduleConstants.Status.NORMAL.getValue().equals(status)) {
+            return resumeJob(job);
         }
-        else if (ScheduleConstants.Status.PAUSE.getValue().equals(status))
-        {
-            rows = pauseJob(job);
+        if (ScheduleConstants.Status.PAUSE.getValue().equals(status)) {
+            return pauseJob(job);
         }
-        return rows;
+        return 0;
     }
 
-    /**
-     * 立即运行任务
-     *
-     * @param job 调度信息
-     */
     @Override
-    public void run(SysJob job)
-    {
-//        Long jobId = job.getJobId();
-//        String jobGroup = job.getJobGroup();
-//        SysJob properties = selectJobById(job.getJobId());
-//        // 参数
-//        JobDataMap dataMap = new JobDataMap();
-//        dataMap.put(ScheduleConstants.TASK_PROPERTIES, properties);
-//        scheduler.triggerJob(ScheduleUtils.getJobKey(jobId, jobGroup), dataMap);
+    public void run(SysJob job) {
+        SysJob properties = selectJobById(job.getJobId());
+        if (properties != null && scheduleJobSyncPublisher != null) {
+            scheduleJobSyncPublisher.publishRun(properties.getJobId(), properties.getJobGroup());
+        }
     }
 
-    /**
-     * 新增任务
-     *
-     * @param job 调度信息 调度信息
-     */
     @Override
-    public int insertJob(SysJob job)
-    {
-//        job.setStatus(ScheduleConstants.Status.PAUSE.getValue());
+    public int insertJob(SysJob job) {
         int rows = jobMapper.insertJob(job);
-        if (rows > 0)
-        {
-//            ScheduleUtils.createScheduleJob(scheduler, job);
+        if (rows > 0) {
+            notifyUpsert(job.getJobId(), job.getJobGroup());
         }
         return rows;
     }
 
-    /**
-     * 更新任务的时间表达式
-     *
-     * @param job 调度信息
-     */
     @Override
-    public int updateJob(SysJob job)
-    {
-        SysJob properties = selectJobById(job.getJobId());
+    public int updateJob(SysJob job) {
         int rows = jobMapper.updateJob(job);
-        if (rows > 0)
-        {
-            updateSchedulerJob(job, properties.getJobGroup());
+        if (rows > 0) {
+            if (ScheduleConstants.Status.PAUSE.getValue().equals(job.getStatus())) {
+                notifyPause(job.getJobId(), job.getJobGroup());
+            } else {
+                notifyUpsert(job.getJobId(), job.getJobGroup());
+            }
         }
         return rows;
     }
 
-    /**
-     * 更新任务
-     *
-     * @param job 任务对象
-     * @param jobGroup 任务组名
-     */
-    public void updateSchedulerJob(SysJob job, String jobGroup)
-    {
-        Long jobId = job.getJobId();
-//        // 判断是否存在
-//        JobKey jobKey = ScheduleUtils.getJobKey(jobId, jobGroup);
-//        if (scheduler.checkExists(jobKey))
-//        {
-//            // 防止创建时存在数据问题 先移除,然后在执行创建操作
-//            scheduler.deleteJob(jobKey);
-//        }
-//        ScheduleUtils.createScheduleJob(scheduler, job);
-    }
-
-    /**
-     * 校验cron表达式是否有效
-     *
-     * @param cronExpression 表达式
-     * @return 结果
-     */
     @Override
-    public boolean checkCronExpressionIsValid(String cronExpression)
-    {
+    public boolean checkCronExpressionIsValid(String cronExpression) {
         return CronUtils.isValid(cronExpression);
     }
+
+    private void notifyUpsert(Long jobId, String jobGroup) {
+        publishSync(ScheduleConstants.JOB_SYNC_UPSERT, jobId, jobGroup);
+    }
+
+    private void notifyPause(Long jobId, String jobGroup) {
+        publishSync(ScheduleConstants.JOB_SYNC_PAUSE, jobId, jobGroup);
+    }
+
+    private void notifyDelete(Long jobId, String jobGroup) {
+        publishSync(ScheduleConstants.JOB_SYNC_DELETE, jobId, jobGroup);
+    }
+
+    /** 通知 fs-task 刷新 Quartz;jobGroup 为空时从主库补全,避免模板开关只传 status 导致暂停失败 */
+    private void publishSync(String action, Long jobId, String jobGroup) {
+        if (scheduleJobSyncPublisher == null || jobId == null) {
+            return;
+        }
+        if (jobGroup == null || jobGroup.isEmpty()) {
+            SysJob job = jobMapper.selectJobById(jobId);
+            if (job != null) {
+                jobGroup = job.getJobGroup();
+            }
+        }
+        switch (action) {
+            case ScheduleConstants.JOB_SYNC_UPSERT:
+                scheduleJobSyncPublisher.publishUpsert(jobId, jobGroup);
+                break;
+            case ScheduleConstants.JOB_SYNC_PAUSE:
+                scheduleJobSyncPublisher.publishPause(jobId, jobGroup);
+                break;
+            case ScheduleConstants.JOB_SYNC_DELETE:
+                scheduleJobSyncPublisher.publishDelete(jobId, jobGroup);
+                break;
+            default:
+                break;
+        }
+    }
 }

+ 1 - 0
fs-service/src/main/java/com/fs/quartz/service/impl/SysJobTemplateServiceImpl.java

@@ -92,6 +92,7 @@ public class SysJobTemplateServiceImpl implements ISysJobTemplateService {
         if (rows > 0) {
             SysJob sysJob = new SysJob();
             sysJob.setJobId(template.getTemplateId());
+            sysJob.setJobGroup(template.getJobGroup());
             sysJobService.deleteJob(sysJob);
         }
         return rows;

+ 47 - 2
fs-service/src/main/java/com/fs/quartz/service/impl/TenantJobConfigServiceImpl.java

@@ -16,6 +16,7 @@ import com.fs.quartz.mapper.SysJobMapper;
 import com.fs.quartz.mapper.SysJobTemplateMapper;
 import com.fs.quartz.mapper.TenantJobConfigMapper;
 import com.fs.quartz.service.ITenantJobConfigService;
+import com.fs.quartz.support.ScheduleJobSyncPublisher;
 import com.fs.system.domain.SysConfig;
 import com.fs.system.mapper.SysConfigMapper;
 import com.fs.tenant.domain.TenantInfo;
@@ -51,6 +52,9 @@ public class TenantJobConfigServiceImpl implements ITenantJobConfigService {
     @Resource
     private SysConfigMapper sysConfigMapper;
 
+    @Resource
+    private ScheduleJobSyncPublisher scheduleJobSyncPublisher;
+
     @Override
     @DataSource(DataSourceType.MASTER)
     public List<TenantJobConfig> selectByTenantId(Long tenantId) {
@@ -63,6 +67,47 @@ public class TenantJobConfigServiceImpl implements ITenantJobConfigService {
         return tenantJobConfigMapper.selectConfigList(query);
     }
 
+    @Override
+    @Transactional
+    public void assignTemplateToTenants(Long templateId, List<Long> tenantIds, String operator) {
+        if (templateId == null) {
+            throw new CustomException("templateId is required");
+        }
+        java.util.Set<Long> distinct = new java.util.LinkedHashSet<>();
+        if (tenantIds != null) {
+            for (Long tenantId : tenantIds) {
+                if (tenantId != null) {
+                    distinct.add(tenantId);
+                }
+            }
+        }
+        List<Long> ids = new ArrayList<>(distinct);
+        tenantJobConfigMapper.deleteByTemplateAndNotIn(templateId, ids);
+        for (Long tenantId : ids) {
+            TenantJobConfig existing = tenantJobConfigMapper.selectByTenantAndTemplate(tenantId, templateId);
+            if (existing == null) {
+                TenantJobConfig row = new TenantJobConfig();
+                row.setTenantId(tenantId);
+                row.setTemplateId(templateId);
+                row.setStatus("0");
+                row.setCreateBy(operator);
+                tenantJobConfigMapper.insertConfig(row);
+            } else if (!"0".equals(existing.getStatus())) {
+                existing.setStatus("0");
+                existing.setUpdateBy(operator);
+                tenantJobConfigMapper.updateConfig(existing);
+            }
+        }
+        log.info("[TenantJobConfig] 模板分配已更新 templateId={}, tenantIds={}", templateId, ids);
+        publishTemplateUpsert(templateId);
+    }
+
+    private void publishTemplateUpsert(Long templateId) {
+        if (scheduleJobSyncPublisher != null && templateId != null) {
+            scheduleJobSyncPublisher.publishUpsert(templateId, null);
+        }
+    }
+
     @Override
     @Transactional
     @DataSource(DataSourceType.MASTER)
@@ -96,6 +141,7 @@ public class TenantJobConfigServiceImpl implements ITenantJobConfigService {
                 tenantJobConfigMapper.updateConfig(existing);
             }
         }
+        publishTemplateUpsert(dto.getTemplateIds() != null && !dto.getTemplateIds().isEmpty() ? dto.getTemplateIds().get(0) : null);
     }
 
     @Override
@@ -120,8 +166,7 @@ public class TenantJobConfigServiceImpl implements ITenantJobConfigService {
         if (configs == null) {
             configs = new ArrayList<>();
         }
-        // 只同步已启用的配置
-        configs.removeIf(c -> !"0".equals(c.getStatus()));
+        // 同步全部配置(含暂停),由 tenant sys_job.status 决定是否真正运行
 
         int synced = 0;
         String syncStatus = "1";

+ 54 - 0
fs-service/src/main/java/com/fs/quartz/support/ScheduleJobSyncPublisher.java

@@ -0,0 +1,54 @@
+package com.fs.quartz.support;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.ScheduleConstants;
+import com.fs.quartz.dto.ScheduleJobSyncMessage;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * 定时任务变更发布器:管理端更新 sys_job 后通过 Redis 通知 fs-task 刷新 Quartz。
+ */
+@Slf4j
+@Component
+public class ScheduleJobSyncPublisher {
+
+    @Autowired(required = false)
+    private RedisTemplate redisTemplate;
+
+    public void publishReloadAll() {
+        publish(ScheduleConstants.JOB_SYNC_RELOAD_ALL, null, null);
+    }
+
+    public void publishUpsert(Long jobId, String jobGroup) {
+        publish(ScheduleConstants.JOB_SYNC_UPSERT, jobId, jobGroup);
+    }
+
+    public void publishPause(Long jobId, String jobGroup) {
+        publish(ScheduleConstants.JOB_SYNC_PAUSE, jobId, jobGroup);
+    }
+
+    public void publishDelete(Long jobId, String jobGroup) {
+        publish(ScheduleConstants.JOB_SYNC_DELETE, jobId, jobGroup);
+    }
+
+    public void publishRun(Long jobId, String jobGroup) {
+        publish(ScheduleConstants.JOB_SYNC_RUN, jobId, jobGroup);
+    }
+
+    private void publish(String action, Long jobId, String jobGroup) {
+        if (redisTemplate == null) {
+            log.debug("[ScheduleJobSync] Redis 不可用,跳过发布 action={}", action);
+            return;
+        }
+        ScheduleJobSyncMessage message = new ScheduleJobSyncMessage();
+        message.setAction(action);
+        message.setJobId(jobId);
+        message.setJobGroup(jobGroup);
+        String payload = JSON.toJSONString(message);
+        redisTemplate.convertAndSend(ScheduleConstants.REDIS_CHANNEL_JOB_SYNC, payload);
+        log.info("[ScheduleJobSync] 已发布 action={} jobId={} jobGroup={}", action, jobId, jobGroup);
+    }
+}

+ 17 - 0
fs-service/src/main/resources/mapper/quartz/SysJobTemplateMapper.xml

@@ -54,6 +54,23 @@
         order by module_tag, template_id
     </select>
 
+    <!-- 根据 jobGroup 和 invokeTarget 查询模板 scope -->
+    <select id="selectScopeByJob" resultType="java.lang.String">
+        select scope from sys_job_template
+        where job_group = #{jobGroup} and invoke_target = #{invokeTarget}
+        limit 1
+    </select>
+
+    <select id="selectScopeByTemplateId" resultType="java.lang.String">
+        select scope from sys_job_template where template_id = #{templateId} limit 1
+    </select>
+
+    <select id="selectTemplateIdByJob" resultType="java.lang.Long">
+        select template_id from sys_job_template
+        where job_group = #{jobGroup} and invoke_target = #{invokeTarget}
+        limit 1
+    </select>
+
     <insert id="insertTemplate" useGeneratedKeys="true" keyProperty="templateId">
         insert into sys_job_template(template_code, job_name, job_group, invoke_target, cron_expression,
             misfire_policy, concurrent, scope, module_tag, default_status, status, remark, create_by, create_time)

+ 40 - 0
fs-service/src/main/resources/mapper/quartz/TenantJobConfigMapper.xml

@@ -60,6 +60,37 @@
         order by t.template_id
     </select>
 
+    <!-- 根据 jobGroup+invokeTarget 查询(兼容旧逻辑,优先使用 selectTenantsByTemplateId) -->
+    <select id="selectTenantsByTemplate" resultMap="TenantJobConfigResult">
+        select c.id, c.tenant_id, c.template_id, c.status, c.cron_expression,
+               t.template_code, t.job_name, t.job_group, t.invoke_target, t.misfire_policy, t.concurrent,
+               ti.tenant_name, ti.tenant_code
+        from tenant_job_config c
+        inner join sys_job_template t on c.template_id = t.template_id
+        left join tenant_info ti on c.tenant_id = ti.id
+        where t.job_group = #{jobGroup}
+          and t.invoke_target = #{invokeTarget}
+          and t.scope = 'TENANT'
+          and t.status = '0'
+          and c.status = '0'
+        order by c.tenant_id
+    </select>
+
+    <!-- 根据模板 ID 精确查询已分配租户(避免同 invokeTarget 多模板串租户) -->
+    <select id="selectTenantsByTemplateId" resultMap="TenantJobConfigResult">
+        select c.id, c.tenant_id, c.template_id, c.status, c.cron_expression,
+               t.template_code, t.job_name, t.job_group, t.invoke_target, t.misfire_policy, t.concurrent,
+               ti.tenant_name, ti.tenant_code
+        from tenant_job_config c
+        inner join sys_job_template t on c.template_id = t.template_id
+        left join tenant_info ti on c.tenant_id = ti.id
+        where c.template_id = #{templateId}
+          and t.scope = 'TENANT'
+          and t.status = '0'
+          and c.status = '0'
+        order by c.tenant_id
+    </select>
+
     <select id="selectByTenantAndTemplate" resultMap="TenantJobConfigResult">
         select id, tenant_id, template_id, status, cron_expression
         from tenant_job_config
@@ -99,6 +130,15 @@
         </if>
     </delete>
 
+    <delete id="deleteByTemplateAndNotIn">
+        delete from tenant_job_config
+        where template_id = #{templateId}
+        <if test="tenantIds != null and tenantIds.size() > 0">
+            and tenant_id not in
+            <foreach collection="tenantIds" item="tid" open="(" separator="," close=")">#{tid}</foreach>
+        </if>
+    </delete>
+
     <update id="updateSyncResult">
         update tenant_job_config
         set sync_status = #{syncStatus}, sync_message = #{syncMessage}, sync_time = sysdate()

+ 3 - 0
fs-task/src/main/java/com/fs/FsTaskApplication.java

@@ -3,11 +3,14 @@ package com.fs;
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.context.annotation.ComponentScan;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.transaction.annotation.EnableTransactionManagement;
 
 /**
  * fs-task 模块启动类
+ * <p>
+ * 显式扫描 com.fs 包,确保 com.fs.task 下的定时任务 Bean(如 companyTask)被正确加载。
  */
 @SpringBootApplication(exclude = { DataSourceAutoConfiguration.class })
 @EnableTransactionManagement

+ 10 - 21
fs-quartz/src/main/java/com/fs/quartz/config/ScheduleConfig.java → fs-task/src/main/java/com/fs/quartz/config/ScheduleConfig.java

@@ -1,59 +1,48 @@
 package com.fs.quartz.config;
 
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.scheduling.quartz.SchedulerFactoryBean;
+
 import javax.sql.DataSource;
 import java.util.Properties;
 
 /**
- * 定时任务配置
- *
-
+ * Quartz 调度器配置(fs-task 进程)。
+ * <p>
+ * 仅当 saas.scheduler.enabled=true 时才创建 Scheduler(默认 false)。
+ * 只有 fs-task / fs-qw-api 这类定时任务执行模块才应开启此开关。
  */
 @Configuration
-public class ScheduleConfig
-{
+@ConditionalOnProperty(name = "saas.scheduler.enabled", havingValue = "true")
+public class ScheduleConfig {
+
     @Bean
-    public SchedulerFactoryBean schedulerFactoryBean(@Qualifier("masterDataSource") DataSource dataSource)
-    {
+    public SchedulerFactoryBean schedulerFactoryBean(@Qualifier("masterDataSource") DataSource dataSource) {
         SchedulerFactoryBean factory = new SchedulerFactoryBean();
         factory.setDataSource(dataSource);
 
-        // quartz参数
         Properties prop = new Properties();
         prop.put("org.quartz.scheduler.instanceName", "FsScheduler");
         prop.put("org.quartz.scheduler.instanceId", "AUTO");
-        // 线程池配置
         prop.put("org.quartz.threadPool.class", "org.quartz.simpl.SimpleThreadPool");
         prop.put("org.quartz.threadPool.threadCount", "20");
         prop.put("org.quartz.threadPool.threadPriority", "5");
-        // JobStore配置 - 不设置class,让Spring自动使用LocalDataSourceJobStore
-        // prop.put("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX");
-        // 集群配置
         prop.put("org.quartz.jobStore.isClustered", "true");
         prop.put("org.quartz.jobStore.clusterCheckinInterval", "15000");
         prop.put("org.quartz.jobStore.maxMisfiresToHandleAtATime", "1");
         prop.put("org.quartz.jobStore.txIsolationLevelSerializable", "true");
-
-        // sqlserver 启用
-        // prop.put("org.quartz.jobStore.selectWithLockSQL", "SELECT * FROM {0}LOCKS UPDLOCK WHERE LOCK_NAME = ?");
         prop.put("org.quartz.jobStore.misfireThreshold", "12000");
         prop.put("org.quartz.jobStore.tablePrefix", "QRTZ_");
         factory.setQuartzProperties(prop);
 
         factory.setSchedulerName("FsScheduler");
-        // 立即启动,避免与 spring-boot-devtools 等导致的「先 shutdown 再延迟 start」冲突(Scheduler cannot be restarted after shutdown)
         factory.setStartupDelay(0);
         factory.setApplicationContextSchedulerContextKey("applicationContextKey");
-        // 可选,QuartzScheduler
-        // 启动时更新己存在的Job,这样就不用每次修改targetObject后删除qrtz_job_details表对应记录了
         factory.setOverwriteExistingJobs(true);
-        // 设置自动启动,默认为true 切记调整为true
         factory.setAutoStartup(true);
-//        factory.setAutoStartup(false);
-
         return factory;
     }
 }

+ 39 - 0
fs-task/src/main/java/com/fs/quartz/config/ScheduleJobRedisConfig.java

@@ -0,0 +1,39 @@
+package com.fs.quartz.config;
+
+import com.fs.common.constant.ScheduleConstants;
+import com.fs.quartz.support.ScheduleJobSyncSubscriber;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+import org.springframework.data.redis.listener.ChannelTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 定时任务 Redis 订阅配置:管理端修改 sys_job 后通过频道通知 fs-task 刷新 Quartz。
+ * <p>
+ * 通过 spring.redis.listener.enabled 控制是否启用(默认 false)。
+ */
+@Slf4j
+@Configuration
+@ConditionalOnProperty(name = "spring.redis.listener.enabled", havingValue = "true", matchIfMissing = false)
+public class ScheduleJobRedisConfig {
+
+    @Bean(initMethod = "start", destroyMethod = "stop")
+    public RedisMessageListenerContainer scheduleJobRedisListenerContainer(
+            RedisConnectionFactory connectionFactory,
+            ScheduleJobSyncSubscriber scheduleJobSyncSubscriber) {
+        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+        container.setConnectionFactory(connectionFactory);
+        container.addMessageListener(scheduleJobSyncSubscriber,
+                new ChannelTopic(ScheduleConstants.REDIS_CHANNEL_JOB_SYNC));
+        container.setMaxSubscriptionRegistrationWaitingTime(TimeUnit.SECONDS.toMillis(10));
+        container.setTaskExecutor(Executors.newFixedThreadPool(2, r -> new Thread(r, "job-sync-listener")));
+        log.info("[ScheduleJobRedis] Redis pub/sub 监听器已启用,频道={}", ScheduleConstants.REDIS_CHANNEL_JOB_SYNC);
+        return container;
+    }
+}

+ 3 - 53
fs-task/src/main/java/com/fs/quartz/service/SysJobInitService.java

@@ -1,71 +1,21 @@
 package com.fs.quartz.service;
 
-import com.fs.quartz.domain.SysJob;
-import com.fs.quartz.mapper.SysJobMapper;
-import com.fs.quartz.util.ScheduleUtils;
-import org.quartz.Scheduler;
-import org.quartz.SchedulerException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
 import javax.annotation.PostConstruct;
-import java.util.List;
 
 /**
- * 项目启动时,从 sys_job 表初始化定时任务到 Quartz 调度器。
- * <p>
- * 作用:确保 Quartz 内部任务与 sys_job 配置表保持一致,
- * 防止手动修改数据库导致未同步到定时任务处理。
- * (注:不能手动修改数据库 job_id 和任务组名,否则会导致脏数据)
- * </p>
+ * 启动时全量加载 sys_job 到 Quartz。
  */
 @Component
 public class SysJobInitService {
 
-    private static final Logger log = LoggerFactory.getLogger(SysJobInitService.class);
-
-    @Autowired
-    private Scheduler scheduler;
-
     @Autowired
-    private SysJobMapper jobMapper;
+    private SysJobSchedulerService sysJobSchedulerService;
 
-    /**
-     * 启动时清空调度器,将 sys_job 表中所有任务重新加载到 Quartz。
-     */
     @PostConstruct
     public void init() {
-        try {
-            log.info("开始从 sys_job 表加载定时任务到 Quartz 调度器...");
-
-            // 清空现有调度器中的所有任务
-            scheduler.clear();
-            log.info("已清空 Quartz 调度器中的所有任务");
-
-            // 从 sys_job 表加载所有任务
-            List<SysJob> jobList = jobMapper.selectJobAll();
-            if (jobList == null || jobList.isEmpty()) {
-                log.info("sys_job 表中无定时任务,初始化完成");
-                return;
-            }
-
-            int successCount = 0;
-            int failCount = 0;
-            for (SysJob job : jobList) {
-                try {
-                    ScheduleUtils.createScheduleJob(scheduler, job);
-                    successCount++;
-                } catch (Exception e) {
-                    failCount++;
-                    log.error("注册定时任务失败 - jobId: {}, jobName: {}, 错误: {}", job.getJobId(), job.getJobName(), e.getMessage());
-                }
-            }
-
-            log.info("定时任务初始化完成 - 总数: {}, 成功: {}, 失败: {}", jobList.size(), successCount, failCount);
-        } catch (SchedulerException e) {
-            log.error("定时任务初始化失败 - 清空调度器异常", e);
-        }
+        sysJobSchedulerService.reloadAll();
     }
 }

+ 171 - 0
fs-task/src/main/java/com/fs/quartz/service/SysJobSchedulerService.java

@@ -0,0 +1,171 @@
+package com.fs.quartz.service;
+
+import com.fs.common.constant.ScheduleConstants;
+import com.fs.common.exception.job.TaskException;
+import com.fs.common.utils.StringUtils;
+import com.fs.quartz.domain.SysJob;
+import com.fs.quartz.mapper.SysJobMapper;
+import com.fs.quartz.util.JobInvokeUtil;
+import com.fs.quartz.util.ScheduleUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.quartz.JobDataMap;
+import org.quartz.JobKey;
+import org.quartz.Scheduler;
+import org.quartz.SchedulerException;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * fs-task 端 Quartz 调度管理服务:全量加载、单条刷新、暂停/删除/立即执行。
+ */
+@Slf4j
+@Service
+public class SysJobSchedulerService {
+
+    @Autowired
+    private Scheduler scheduler;
+
+    @Autowired
+    private SysJobMapper jobMapper;
+
+    public void reloadAll() {
+        try {
+            log.info("[SysJobScheduler] 开始全量加载主库 sys_job 到 Quartz...");
+            scheduler.clear();
+            List<SysJob> jobList = jobMapper.selectJobAll();
+            if (jobList == null || jobList.isEmpty()) {
+                log.info("[SysJobScheduler] sys_job 为空,加载结束");
+                return;
+            }
+            // 只加载启用的任务(status='0'),暂停/停用的任务不注册
+            jobList.removeIf(j -> !ScheduleConstants.Status.NORMAL.getValue().equals(j.getStatus()));
+            if (jobList.isEmpty()) {
+                log.info("[SysJobScheduler] 无启用的任务,加载结束");
+                return;
+            }
+            int success = 0;
+            int fail = 0;
+            for (SysJob job : jobList) {
+                if (!JobInvokeUtil.isInvokeTargetAvailable(job)) {
+                    log.info("[SysJobScheduler] 跳过不可用任务(非本模块 Bean): jobId={}, jobName={}, invokeTarget={}",
+                            job.getJobId(), job.getJobName(), job.getInvokeTarget());
+                    continue;
+                }
+                if (registerJob(job)) {
+                    success++;
+                } else {
+                    fail++;
+                }
+            }
+            log.info("[SysJobScheduler] 全量加载完成 total={} success={} fail={}", jobList.size(), success, fail);
+        } catch (SchedulerException e) {
+            log.error("[SysJobScheduler] 全量加载失败", e);
+        }
+    }
+
+    public void upsertJob(Long jobId) {
+        if (jobId == null) {
+            return;
+        }
+        SysJob job = jobMapper.selectJobById(jobId);
+        if (job == null) {
+            log.warn("[SysJobScheduler] upsert 时任务不存在 jobId={}", jobId);
+            return;
+        }
+        if (!JobInvokeUtil.isInvokeTargetAvailable(job)) {
+            log.info("[SysJobScheduler] upsert 跳过不可用任务: jobId={}, invokeTarget={}", jobId, job.getInvokeTarget());
+            return;
+        }
+        registerJob(job);
+    }
+
+    public void pauseJob(Long jobId, String jobGroup) {
+        if (jobId == null) {
+            return;
+        }
+        // 收集所有可能的 jobGroup 进行删除尝试
+        java.util.Set<String> groupsToTry = new java.util.LinkedHashSet<>();
+        if (StringUtils.isNotEmpty(jobGroup)) {
+            groupsToTry.add(jobGroup);
+        }
+        // 从主库补全
+        SysJob job = jobMapper.selectJobById(jobId);
+        if (job != null && StringUtils.isNotEmpty(job.getJobGroup())) {
+            groupsToTry.add(job.getJobGroup());
+        }
+        // 总是尝试 null group 作为兜底
+        groupsToTry.add(null);
+
+        boolean deleted = false;
+        for (String g : groupsToTry) {
+            try {
+                JobKey jobKey = ScheduleUtils.getJobKey(jobId, g);
+                if (scheduler.checkExists(jobKey)) {
+                    scheduler.deleteJob(jobKey);
+                    log.info("[SysJobScheduler] 停用任务已从 Quartz 删除 jobId={} jobGroup={}", jobId, g);
+                    deleted = true;
+                }
+            } catch (SchedulerException e) {
+                log.warn("[SysJobScheduler] 删除尝试失败 jobId={} jobGroup={}", jobId, g, e);
+            }
+        }
+        if (!deleted) {
+            log.info("[SysJobScheduler] 停用任务在 Quartz 中不存在(可能已删除或未注册)jobId={}", jobId);
+        }
+    }
+
+    public void deleteJob(Long jobId, String jobGroup) {
+        if (jobId == null) {
+            return;
+        }
+        try {
+            JobKey jobKey = ScheduleUtils.getJobKey(jobId, jobGroup);
+            if (scheduler.checkExists(jobKey)) {
+                scheduler.deleteJob(jobKey);
+                log.info("[SysJobScheduler] 已删除 jobId={} jobGroup={}", jobId, jobGroup);
+            }
+        } catch (SchedulerException e) {
+            log.error("[SysJobScheduler] 删除失败 jobId={}", jobId, e);
+        }
+    }
+
+    public void runJob(Long jobId, String jobGroup) {
+        if (jobId == null) {
+            return;
+        }
+        SysJob job = jobMapper.selectJobById(jobId);
+        if (job == null) {
+            log.warn("[SysJobScheduler] run 时任务不存在 jobId={}", jobId);
+            return;
+        }
+        if (!JobInvokeUtil.isInvokeTargetAvailable(job)) {
+            log.info("[SysJobScheduler] run 跳过不可用任务: jobId={}, invokeTarget={}", jobId, job.getInvokeTarget());
+            return;
+        }
+        try {
+            JobKey jobKey = ScheduleUtils.getJobKey(jobId, jobGroup != null ? jobGroup : job.getJobGroup());
+            if (!scheduler.checkExists(jobKey)) {
+                registerJob(job);
+            }
+            JobDataMap dataMap = new JobDataMap();
+            dataMap.put(ScheduleConstants.TASK_PROPERTIES, job);
+            scheduler.triggerJob(jobKey, dataMap);
+            log.info("[SysJobScheduler] 已触发执行 jobId={}", jobId);
+        } catch (SchedulerException e) {
+            log.error("[SysJobScheduler] 立即执行失败 jobId={}", jobId, e);
+        }
+    }
+
+    private boolean registerJob(SysJob job) {
+        try {
+            ScheduleUtils.createScheduleJob(scheduler, job);
+            return true;
+        } catch (Exception e) {
+            log.error("[SysJobScheduler] 注册失败(可能 cron 非法)jobId={} jobName={} cron={}",
+                    job.getJobId(), job.getJobName(), job.getCronExpression(), e);
+            return false;
+        }
+    }
+}

+ 66 - 0
fs-task/src/main/java/com/fs/quartz/support/ScheduleJobSyncSubscriber.java

@@ -0,0 +1,66 @@
+package com.fs.quartz.support;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.constant.ScheduleConstants;
+import com.fs.quartz.dto.ScheduleJobSyncMessage;
+import com.fs.quartz.service.SysJobSchedulerService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.stereotype.Component;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 定时任务同步消息订阅者:接收 Redis 消息后刷新 Quartz 调度器。
+ */
+@Slf4j
+@Component
+public class ScheduleJobSyncSubscriber implements MessageListener {
+
+    @Autowired
+    private SysJobSchedulerService sysJobSchedulerService;
+
+    @Override
+    public void onMessage(Message message, byte[] pattern) {
+        String body = new String(message.getBody(), StandardCharsets.UTF_8);
+        try {
+            ScheduleJobSyncMessage syncMessage = JSON.parseObject(body, ScheduleJobSyncMessage.class);
+            if (syncMessage == null || syncMessage.getAction() == null) {
+                log.warn("[ScheduleJobSync] 无效消息 body={}", body);
+                return;
+            }
+            dispatch(syncMessage);
+        } catch (Exception e) {
+            log.error("[ScheduleJobSync] 处理消息失败 body={}", body, e);
+        }
+    }
+
+    private void dispatch(ScheduleJobSyncMessage message) {
+        String action = message.getAction();
+        Long jobId = message.getJobId();
+        String jobGroup = message.getJobGroup();
+        log.info("[ScheduleJobSync] 收到消息 action={} jobId={} jobGroup={}", action, jobId, jobGroup);
+
+        switch (action) {
+            case ScheduleConstants.JOB_SYNC_RELOAD_ALL:
+                sysJobSchedulerService.reloadAll();
+                break;
+            case ScheduleConstants.JOB_SYNC_UPSERT:
+                sysJobSchedulerService.upsertJob(jobId);
+                break;
+            case ScheduleConstants.JOB_SYNC_PAUSE:
+                sysJobSchedulerService.pauseJob(jobId, jobGroup);
+                break;
+            case ScheduleConstants.JOB_SYNC_DELETE:
+                sysJobSchedulerService.deleteJob(jobId, jobGroup);
+                break;
+            case ScheduleConstants.JOB_SYNC_RUN:
+                sysJobSchedulerService.runJob(jobId, jobGroup);
+                break;
+            default:
+                log.warn("[ScheduleJobSync] 未知 action={}", action);
+        }
+    }
+}

+ 73 - 0
fs-task/src/main/java/com/fs/quartz/support/TenantTaskContextHelper.java

@@ -0,0 +1,73 @@
+package com.fs.quartz.support;
+
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.enums.DataSourceType;
+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.stereotype.Component;
+
+import java.util.function.Supplier;
+
+/**
+ * 任务内异步/子线程切租户上下文辅助工具。
+ * <p>
+ * Quartz 入口已由 MultiScopeJobDispatcher 统一切租户;
+ * 若任务内使用 @Async 或自建线程池,子线程须通过此工具传入 tenantId 再切库。
+ */
+@Slf4j
+@Component
+public class TenantTaskContextHelper {
+
+    @Autowired
+    private TenantInfoService tenantInfoService;
+    @Autowired
+    private TenantDataSourceManager tenantDataSourceManager;
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    public void runWithTenant(Long tenantId, Runnable action) {
+        runWithTenant(tenantId, () -> {
+            action.run();
+            return null;
+        });
+    }
+
+    public <T> T runWithTenant(Long tenantId, Supplier<T> action) {
+        if (tenantId == null) {
+            throw new IllegalArgumentException("tenantId is required");
+        }
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            TenantInfo tenant = tenantInfoService.getById(tenantId);
+            if (tenant == null) {
+                throw new IllegalStateException("tenant not found: " + tenantId);
+            }
+            tenantDataSourceManager.switchTenant(tenant);
+            RedisTenantContext.setTenantId(tenantId);
+            SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+            ProjectConfig.safeLoadTenantConfigFromValue(cfg != null ? cfg.getConfigValue() : null);
+            return action.get();
+        } catch (Exception e) {
+            log.error("[TenantTaskContext] 租户任务执行失败 tenantId={}", tenantId, e);
+            throw e;
+        } finally {
+            cleanup();
+        }
+    }
+
+    private void cleanup() {
+        try { ProjectConfig.clearTenantConfigs(); } catch (Exception ignored) { }
+        try { TenantConfigContext.clear(); } catch (Exception ignored) { }
+        try { RedisTenantContext.clear(); } catch (Exception ignored) { }
+        try { DynamicDataSourceContextHolder.clearDataSourceType(); } catch (Exception ignored) { }
+        try { tenantDataSourceManager.clear(); } catch (Exception ignored) { }
+    }
+}

+ 8 - 3
fs-task/src/main/java/com/fs/quartz/task/RyTask.java

@@ -1,11 +1,14 @@
 package com.fs.quartz.task;
 
 import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.SysRedpacketConfigMore;
+import com.fs.his.service.ISysRedpacketConfigMoreService;
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
 /**
  * 定时任务调度测试
- * 
+ *
 
  */
 @Component("ryTask")
@@ -20,9 +23,11 @@ public class RyTask
     {
         System.out.println("执行有参方法:" + params);
     }
-
+    @Autowired
+    private ISysRedpacketConfigMoreService sysRedpacketConfigMoreService;
     public void ryNoParams()
     {
-        System.out.println("执行无参方法");
+        System.out.println("当前Redis租户ID: {}"+com.fs.common.config.RedisTenantContext.getTenantId());
+        System.out.println(sysRedpacketConfigMoreService.selectSysRedpacketConfigMoreList(new SysRedpacketConfigMore()));
     }
 }

+ 75 - 56
fs-task/src/main/java/com/fs/quartz/util/AbstractQuartzJob.java

@@ -2,13 +2,17 @@ package com.fs.quartz.util;
 
 import com.fs.common.constant.Constants;
 import com.fs.common.constant.ScheduleConstants;
+import com.fs.common.enums.DataSourceType;
 import com.fs.common.utils.ExceptionUtil;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.bean.BeanUtils;
 import com.fs.common.utils.spring.SpringUtils;
+import com.fs.framework.datasource.DynamicDataSourceContextHolder;
 import com.fs.quartz.domain.SysJob;
 import com.fs.quartz.domain.SysJobLog;
+import com.fs.quartz.mapper.SysJobMapper;
 import com.fs.quartz.service.ISysJobLogService;
+import com.fs.quartz.util.JobInvokeUtil;
 import org.quartz.Job;
 import org.quartz.JobExecutionContext;
 import org.quartz.JobExecutionException;
@@ -18,60 +22,62 @@ import org.slf4j.LoggerFactory;
 import java.util.Date;
 
 /**
- * 抽象quartz调用
- *
+ * Quartz Job 抽象基类:校验任务状态、记录执行时间、调用子类 doExecute、写主库 sys_job_log 汇总日志。
+ * <p>
+ * 平台级/租户级分发由 {@link MultiScopeJobDispatcher} 完成(依据 sys_job_template.scope)。
  */
-public abstract class AbstractQuartzJob implements Job
-{
+public abstract class AbstractQuartzJob implements Job {
+
     private static final Logger log = LoggerFactory.getLogger(AbstractQuartzJob.class);
 
-    /**
-     * 线程本地变量
-     */
-    private static ThreadLocal<Date> threadLocal = new ThreadLocal<>();
+    private static final ThreadLocal<Date> THREAD_LOCAL = new ThreadLocal<>();
 
     @Override
-    public void execute(JobExecutionContext context) throws JobExecutionException
-    {
+    public void execute(JobExecutionContext context) throws JobExecutionException {
         SysJob sysJob = new SysJob();
         BeanUtils.copyBeanProp(sysJob, context.getMergedJobDataMap().get(ScheduleConstants.TASK_PROPERTIES));
-        try
-        {
+
+        if (sysJob == null || sysJob.getJobId() == null) {
+            log.warn("任务参数无效,跳过执行");
+            return;
+        }
+
+        // 关键优化:若当前进程不存在目标 Bean,立即跳过,避免 NoSuchBeanDefinitionException 和 ERROR 日志
+        if (!JobInvokeUtil.isInvokeTargetAvailable(sysJob)) {
+            log.info("任务目标在当前模块不可用,跳过执行: jobId={}, jobName={}, invokeTarget={}",
+                    sysJob.getJobId(), sysJob.getJobName(), sysJob.getInvokeTarget());
+            return;
+        }
+
+        // 执行前从主库刷新状态,避免管理端暂停/停用后 Quartz 仍用注册时的旧快照继续跑
+        sysJob = refreshJobStatus(sysJob);
+
+        if (!ScheduleConstants.Status.NORMAL.getValue().equals(sysJob.getStatus())) {
+            log.info("任务已暂停/停用,跳过执行: jobId={}, jobName={}, status={}",
+                    sysJob.getJobId(), sysJob.getJobName(), sysJob.getStatus());
+            return;
+        }
+
+        try {
             before(context, sysJob);
-            if (sysJob != null)
-            {
-                doExecute(context, sysJob);
-            }
+            doExecute(context, sysJob);
             after(context, sysJob, null);
-        }
-        catch (Exception e)
-        {
-            log.error("任务执行异常  - :", e);
+        } catch (Exception e) {
+            log.error("任务执行异常:jobId={}, jobName={}", sysJob.getJobId(), sysJob.getJobName(), e);
             after(context, sysJob, e);
         }
     }
 
-    /**
-     * 执行前
-     *
-     * @param context 工作执行上下文对象
-     * @param sysJob 系统计划任务
-     */
-    protected void before(JobExecutionContext context, SysJob sysJob)
-    {
-        threadLocal.set(new Date());
+    protected void before(JobExecutionContext context, SysJob sysJob) {
+        THREAD_LOCAL.set(new Date());
     }
 
-    /**
-     * 执行后
-     *
-     * @param context 工作执行上下文对象
-     * @param sysJob 系统计划任务
-     */
-    protected void after(JobExecutionContext context, SysJob sysJob, Exception e)
-    {
-        Date startTime = threadLocal.get();
-        threadLocal.remove();
+    protected void after(JobExecutionContext context, SysJob sysJob, Exception e) {
+        Date startTime = THREAD_LOCAL.get();
+        THREAD_LOCAL.remove();
+        if (startTime == null) {
+            return;
+        }
 
         final SysJobLog sysJobLog = new SysJobLog();
         sysJobLog.setJobName(sysJob.getJobName());
@@ -80,28 +86,41 @@ public abstract class AbstractQuartzJob implements Job
         sysJobLog.setStartTime(startTime);
         sysJobLog.setStopTime(new Date());
         long runMs = sysJobLog.getStopTime().getTime() - sysJobLog.getStartTime().getTime();
-        sysJobLog.setJobMessage(sysJobLog.getJobName() + " 总共耗时:" + runMs + "毫秒");
-        if (e != null)
-        {
+        sysJobLog.setJobMessage(sysJob.getJobName() + " 调度汇总 耗时:" + runMs + "毫秒");
+
+        if (e != null) {
             sysJobLog.setStatus(Constants.FAIL);
-            String errorMsg = StringUtils.substring(ExceptionUtil.getExceptionMessage(e), 0, 2000);
-            sysJobLog.setExceptionInfo(errorMsg);
-        }
-        else
-        {
+            sysJobLog.setExceptionInfo(StringUtils.substring(ExceptionUtil.getExceptionMessage(e), 0, 2000));
+        } else {
             sysJobLog.setStatus(Constants.SUCCESS);
         }
 
-        // 写入数据库当中
-        SpringUtils.getBean(ISysJobLogService.class).addJobLog(sysJobLog);
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            SpringUtils.getBean(ISysJobLogService.class).addJobLog(sysJobLog);
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
     }
 
-    /**
-     * 执行方法,由子类重载
-     *
-     * @param context 工作执行上下文对象
-     * @param sysJob 系统计划任务
-     * @throws Exception 执行过程中的异常
-     */
     protected abstract void doExecute(JobExecutionContext context, SysJob sysJob) throws Exception;
+
+    /** 从主库 sys_job 读取最新状态(Redis 未同步时也能生效) */
+    private SysJob refreshJobStatus(SysJob cached) {
+        if (cached == null || cached.getJobId() == null) {
+            return cached;
+        }
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            SysJob fresh = SpringUtils.getBean(SysJobMapper.class).selectJobById(cached.getJobId());
+            if (fresh != null) {
+                return fresh;
+            }
+        } catch (Exception e) {
+            log.warn("刷新任务状态失败,使用缓存状态: jobId={}", cached.getJobId(), e);
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+        return cached;
+    }
 }

+ 57 - 20
fs-task/src/main/java/com/fs/quartz/util/JobInvokeUtil.java

@@ -3,6 +3,7 @@ package com.fs.quartz.util;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.quartz.domain.SysJob;
+import org.springframework.beans.BeansException;
 
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
@@ -28,15 +29,27 @@ public class JobInvokeUtil
         String methodName = getMethodName(invokeTarget);
         List<Object[]> methodParams = getMethodParams(invokeTarget);
 
+        Object bean = resolveBean(beanName);
+        invokeMethod(bean, methodName, methodParams);
+    }
+
+    /**
+     * 解析任务目标 Bean:优先从 Spring 容器获取(支持 CGLIB 代理),类名方式兜底。
+     */
+    private static Object resolveBean(String beanName) throws Exception
+    {
         if (!isValidClassName(beanName))
         {
-            Object bean = SpringUtils.getBean(beanName);
-            invokeMethod(bean, methodName, methodParams);
+            return SpringUtils.getBean(beanName);
         }
-        else
+        Class<?> clazz = Class.forName(beanName);
+        try
+        {
+            return SpringUtils.getBean(clazz);
+        }
+        catch (BeansException ex)
         {
-            Object bean = Class.forName(beanName).newInstance();
-            invokeMethod(bean, methodName, methodParams);
+            return clazz.getDeclaredConstructor().newInstance();
         }
     }
 
@@ -50,20 +63,21 @@ public class JobInvokeUtil
         {
             return false;
         }
-        String beanName = getBeanName(sysJob.getInvokeTarget());
-        if (isValidClassName(beanName))
+        try
         {
-            try
-            {
-                Class.forName(beanName);
-                return true;
-            }
-            catch (ClassNotFoundException e)
-            {
-                return false;
-            }
+            String beanName = getBeanName(sysJob.getInvokeTarget());
+            String methodName = getMethodName(sysJob.getInvokeTarget());
+            List<Object[]> methodParams = getMethodParams(sysJob.getInvokeTarget());
+            Object bean = resolveBean(beanName);
+            Class<?>[] paramTypes = methodParams != null && !methodParams.isEmpty()
+                    ? getMethodParamsType(methodParams) : new Class<?>[0];
+            resolveMethod(bean.getClass(), methodName, paramTypes);
+            return true;
+        }
+        catch (Exception e)
+        {
+            return false;
         }
-        return SpringUtils.containsBean(beanName);
     }
 
     /**
@@ -77,18 +91,41 @@ public class JobInvokeUtil
             throws NoSuchMethodException, SecurityException, IllegalAccessException, IllegalArgumentException,
             InvocationTargetException
     {
-        if (StringUtils.isNotNull(methodParams) && methodParams.size() > 0)
+        Class<?>[] paramTypes = StringUtils.isNotNull(methodParams) && methodParams.size() > 0
+                ? getMethodParamsType(methodParams) : new Class<?>[0];
+        Method method = resolveMethod(bean.getClass(), methodName, paramTypes);
+        method.setAccessible(true);
+        if (paramTypes.length > 0)
         {
-            Method method = bean.getClass().getDeclaredMethod(methodName, getMethodParamsType(methodParams));
             method.invoke(bean, getMethodParamsValue(methodParams));
         }
         else
         {
-            Method method = bean.getClass().getDeclaredMethod(methodName);
             method.invoke(bean);
         }
     }
 
+    /**
+     * 解析目标方法:沿类继承链查找,兼容 Spring CGLIB/JDK 代理子类。
+     */
+    private static Method resolveMethod(Class<?> clazz, String methodName, Class<?>... paramTypes)
+            throws NoSuchMethodException
+    {
+        Class<?> searchType = clazz;
+        while (searchType != null && searchType != Object.class)
+        {
+            try
+            {
+                return searchType.getDeclaredMethod(methodName, paramTypes);
+            }
+            catch (NoSuchMethodException ignored)
+            {
+                searchType = searchType.getSuperclass();
+            }
+        }
+        return clazz.getMethod(methodName, paramTypes);
+    }
+
     /**
      * 校验是否为为class包名
      *

+ 233 - 0
fs-task/src/main/java/com/fs/quartz/util/MultiScopeJobDispatcher.java

@@ -0,0 +1,233 @@
+package com.fs.quartz.util;
+
+import com.fs.common.config.RedisTenantContext;
+import com.fs.common.constant.Constants;
+import com.fs.common.constant.ScheduleConstants;
+import com.fs.common.enums.DataSourceType;
+import com.fs.common.utils.ExceptionUtil;
+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.domain.SysJobLog;
+import com.fs.quartz.domain.TenantJobConfig;
+import com.fs.quartz.mapper.SysJobTemplateMapper;
+import com.fs.quartz.mapper.TenantJobConfigMapper;
+import com.fs.quartz.service.ISysJobLogService;
+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.quartz.JobExecutionContext;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+public class MultiScopeJobDispatcher {
+    @Autowired private SysJobTemplateMapper sysJobTemplateMapper;
+    @Autowired private TenantJobConfigMapper tenantJobConfigMapper;
+    @Autowired private TenantInfoService tenantInfoService;
+    @Autowired private TenantDataSourceManager tenantDataSourceManager;
+    @Autowired private SysConfigMapper sysConfigMapper;
+    @Autowired(required = false) private ISysJobLogService sysJobLogService;
+    @Value("${saas.task.parallel.threads:4}") private int parallelThreads;
+    private volatile ExecutorService tenantExecutor;
+
+    public void dispatch(JobExecutionContext context, SysJob sysJob, ScopedJobExecutor executor) throws Exception {
+        if (!JobInvokeUtil.isInvokeTargetAvailable(sysJob)) {
+            log.warn("[MultiScopeJob] invokeTarget 在当前进程不可用,跳过执行: jobName={}, invokeTarget={}, jobGroup={}",
+                    sysJob.getJobName(), sysJob.getInvokeTarget(), sysJob.getJobGroup());
+            return;
+        }
+        String scope = resolveScope(sysJob);
+        if (ScheduleConstants.JobScope.TENANT.getValue().equals(scope)) {
+            log.info("[MultiScopeJob] 租户级任务: jobName={}, templateId={}", sysJob.getJobName(), sysJob.getJobId());
+            executeTenantJob(context, sysJob, executor);
+        } else {
+            log.debug("[MultiScopeJob] 平台级任务: jobName={}", sysJob.getJobName());
+            executePlatformJob(context, sysJob, executor);
+        }
+    }
+
+    private String resolveScope(SysJob sysJob) {
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            Long templateId = resolveTemplateId(sysJob);
+            if (templateId != null) {
+                String scope = sysJobTemplateMapper.selectScopeByTemplateId(templateId);
+                if (StringUtils.isNotEmpty(scope)) {
+                    return scope;
+                }
+            }
+            return ScheduleConstants.JobScope.PLATFORM.getValue();
+        } catch (Exception e) {
+            log.warn("[MultiScopeJob] 查询模板 scope 失败,按平台级处理: jobName={}", sysJob.getJobName(), e);
+            return ScheduleConstants.JobScope.PLATFORM.getValue();
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    private Long resolveTemplateId(SysJob sysJob) {
+        if (sysJob.getJobId() != null) {
+            String scope = sysJobTemplateMapper.selectScopeByTemplateId(sysJob.getJobId());
+            if (StringUtils.isNotEmpty(scope)) {
+                return sysJob.getJobId();
+            }
+        }
+        return sysJobTemplateMapper.selectTemplateIdByJob(sysJob.getJobGroup(), sysJob.getInvokeTarget());
+    }
+
+    private void executePlatformJob(JobExecutionContext context, SysJob sysJob, ScopedJobExecutor executor) throws Exception {
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            executor.execute(context, sysJob);
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    private void executeTenantJob(JobExecutionContext context, SysJob sysJob, ScopedJobExecutor executor) throws Exception {
+        List<TenantJobConfig> tenantConfigs = queryAssignedTenants(sysJob);
+        if (tenantConfigs.isEmpty()) {
+            log.warn("[MultiScopeJob] 租户级任务未分配任何租户: jobName={}, templateId={}",
+                    sysJob.getJobName(), sysJob.getJobId());
+            return;
+        }
+        log.info("[MultiScopeJob] 开始并行执行租户任务: jobName={}, templateId={}, 租户数={}, tenantIds={}",
+                sysJob.getJobName(), sysJob.getJobId(), tenantConfigs.size(),
+                tenantConfigs.stream().map(TenantJobConfig::getTenantId).collect(Collectors.toList()));
+        CountDownLatch latch = new CountDownLatch(tenantConfigs.size());
+        AtomicInteger failCount = new AtomicInteger(0);
+        for (TenantJobConfig config : tenantConfigs) {
+            getTenantExecutor().submit(() -> {
+                if (executeForOneTenant(context, sysJob, config, executor)) failCount.incrementAndGet();
+                latch.countDown();
+            });
+        }
+        try {
+            if (!latch.await(30, TimeUnit.MINUTES)) {
+                throw new IllegalStateException("租户任务执行超时: " + sysJob.getJobName());
+            }
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            throw new IllegalStateException("租户任务执行被中断: " + sysJob.getJobName(), e);
+        }
+        if (failCount.get() > 0) {
+            throw new IllegalStateException("租户任务部分失败: jobName=" + sysJob.getJobId() + ", 失败数=" + failCount.get());
+        }
+    }
+
+    private List<TenantJobConfig> queryAssignedTenants(SysJob sysJob) {
+        try {
+            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+            Long templateId = resolveTemplateId(sysJob);
+            if (templateId == null) {
+                log.warn("[MultiScopeJob] 无法解析 templateId,跳过租户分发: jobName={}, jobId={}",
+                        sysJob.getJobName(), sysJob.getJobId());
+                return Collections.emptyList();
+            }
+            List<TenantJobConfig> list = tenantJobConfigMapper.selectTenantsByTemplateId(templateId);
+            return list != null ? list : Collections.emptyList();
+        } finally {
+            DynamicDataSourceContextHolder.clearDataSourceType();
+        }
+    }
+
+    private boolean executeForOneTenant(JobExecutionContext context, SysJob sysJob, TenantJobConfig config, ScopedJobExecutor executor) {
+        Long tenantId = config.getTenantId();
+        String tenantCode = StringUtils.isNotEmpty(config.getTenantCode()) ? config.getTenantCode() : String.valueOf(tenantId);
+        Date startTime = new Date();
+        Exception executionError = null;
+        boolean tenantSwitched = false;
+        try {
+            TenantInfo tenantInfo = loadTenantInfo(tenantId);
+            if (tenantInfo == null) {
+                log.error("[MultiScopeJob] 租户不存在: tenantId={}", tenantId);
+                return true;
+            }
+            switchToTenant(tenantInfo);
+            tenantSwitched = true;
+            log.info("[MultiScopeJob] 租户开始执行: jobName={}, tenantId={}", sysJob.getJobName(), tenantId);
+            executor.execute(context, sysJob);
+            return false;
+        } catch (Exception e) {
+            executionError = e;
+            log.error("[MultiScopeJob] 租户执行失败: tenantId={}", tenantId, e);
+            return true;
+        } finally {
+            if (tenantSwitched) {
+                try { saveJobLog(sysJob, tenantCode, startTime, executionError, true); } catch (Exception ignored) { }
+            }
+            cleanup();
+            try { saveJobLog(sysJob, tenantCode, startTime, executionError, false); } catch (Exception ignored) { }
+        }
+    }
+
+    private TenantInfo loadTenantInfo(Long tenantId) {
+        DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        return tenantInfoService.getById(tenantId);
+    }
+
+    private void switchToTenant(TenantInfo tenantInfo) {
+        tenantDataSourceManager.switchTenant(tenantInfo);
+        RedisTenantContext.setTenantId(tenantInfo.getId());
+        SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
+        ProjectConfig.safeLoadTenantConfigFromValue(cfg != null ? cfg.getConfigValue() : null);
+    }
+
+    private void saveJobLog(SysJob sysJob, String tenantCode, Date startTime, Exception e, boolean inTenantDb) {
+        if (sysJobLogService == null) return;
+        if (!inTenantDb) DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
+        SysJobLog jobLog = new SysJobLog();
+        jobLog.setJobName(sysJob.getJobName());
+        jobLog.setJobGroup(sysJob.getJobGroup());
+        jobLog.setInvokeTarget(sysJob.getInvokeTarget());
+        jobLog.setStartTime(startTime);
+        jobLog.setStopTime(new Date());
+        long runMs = jobLog.getStopTime().getTime() - startTime.getTime();
+        String dbTag = inTenantDb ? "租户库" : "主库";
+        jobLog.setJobMessage(sysJob.getJobName() + " [" + tenantCode + "] " + dbTag + " 耗时:" + runMs + "毫秒");
+        jobLog.setStatus(e != null ? Constants.FAIL : Constants.SUCCESS);
+        if (e != null) jobLog.setExceptionInfo(StringUtils.substring(ExceptionUtil.getExceptionMessage(e), 0, 2000));
+        sysJobLogService.addJobLog(jobLog);
+    }
+
+    private ExecutorService getTenantExecutor() {
+        if (tenantExecutor == null) {
+            synchronized (this) {
+                if (tenantExecutor == null) {
+                    int threads = Math.max(2, parallelThreads);
+                    tenantExecutor = new ThreadPoolExecutor(threads, threads, 60L, TimeUnit.SECONDS,
+                            new LinkedBlockingQueue<>(256), r -> new Thread(r, "tenant-job-" + System.identityHashCode(r)),
+                            new ThreadPoolExecutor.CallerRunsPolicy());
+                }
+            }
+        }
+        return tenantExecutor;
+    }
+
+    private void cleanup() {
+        try { ProjectConfig.clearTenantConfigs(); } catch (Exception ignored) { }
+        try { TenantConfigContext.clear(); } catch (Exception ignored) { }
+        try { RedisTenantContext.clear(); } catch (Exception ignored) { }
+        try { DynamicDataSourceContextHolder.clearDataSourceType(); } catch (Exception ignored) { }
+        try { tenantDataSourceManager.clear(); } catch (Exception ignored) { }
+    }
+}

+ 10 - 9
fs-task/src/main/java/com/fs/quartz/util/QuartzDisallowConcurrentExecution.java

@@ -1,21 +1,22 @@
 package com.fs.quartz.util;
 
+import com.fs.common.utils.spring.SpringUtils;
 import com.fs.quartz.domain.SysJob;
 import org.quartz.DisallowConcurrentExecution;
 import org.quartz.JobExecutionContext;
 
 /**
- * 定时任务处理(禁止并发执行)
- * 
-
- *
+ * 定时任务处理(禁止并发执行,concurrent=1
+ * <p>
+ * 平台级:主库直接调用 invokeTarget。
+ * 租户级:由 MultiScopeJobDispatcher 查 tenant_job_config 分配租户后并行切库执行。
  */
 @DisallowConcurrentExecution
-public class QuartzDisallowConcurrentExecution extends AbstractQuartzJob
-{
+public class QuartzDisallowConcurrentExecution extends AbstractQuartzJob {
+
     @Override
-    protected void doExecute(JobExecutionContext context, SysJob sysJob) throws Exception
-    {
-        JobInvokeUtil.invokeMethod(sysJob);
+    protected void doExecute(JobExecutionContext context, SysJob sysJob) throws Exception {
+        MultiScopeJobDispatcher dispatcher = SpringUtils.getBean(MultiScopeJobDispatcher.class);
+        dispatcher.dispatch(context, sysJob, (ctx, job) -> JobInvokeUtil.invokeMethod(job));
     }
 }

+ 9 - 13
fs-task/src/main/java/com/fs/quartz/util/QuartzJobExecution.java

@@ -2,23 +2,19 @@ package com.fs.quartz.util;
 
 import com.fs.common.utils.spring.SpringUtils;
 import com.fs.quartz.domain.SysJob;
-import com.fs.quartz.service.ISysJobService;
 import org.quartz.JobExecutionContext;
 
 /**
- * 定时任务处理(允许并发执行)
+ * 定时任务处理(允许并发执行,concurrent=0)。
+ * <p>
+ * 平台级:主库直接调用 invokeTarget。
+ * 租户级:由 MultiScopeJobDispatcher 查 tenant_job_config 分配租户后并行切库执行。
  */
-public class QuartzJobExecution extends AbstractQuartzJob
-{
-    @Override
-    protected void doExecute(JobExecutionContext context, SysJob sysJob) throws Exception
-    {
-        // 检查Job状态是否正常
-        ISysJobService jobService = SpringUtils.getBean(ISysJobService.class);
-
-        // 检查Job与租户关系是否正常
+public class QuartzJobExecution extends AbstractQuartzJob {
 
-        // 线程池并发执行
-        JobInvokeUtil.invokeMethod(sysJob);
+    @Override
+    protected void doExecute(JobExecutionContext context, SysJob sysJob) throws Exception {
+        MultiScopeJobDispatcher dispatcher = SpringUtils.getBean(MultiScopeJobDispatcher.class);
+        dispatcher.dispatch(context, sysJob, (ctx, job) -> JobInvokeUtil.invokeMethod(job));
     }
 }

+ 3 - 5
fs-task/src/main/java/com/fs/quartz/util/ScheduleUtils.java

@@ -71,12 +71,10 @@ public class ScheduleUtils
             scheduler.deleteJob(getJobKey(jobId, jobGroup));
         }
 
-        scheduler.scheduleJob(jobDetail, trigger);
-
-        // 暂停任务
-        if (job.getStatus().equals(ScheduleConstants.Status.PAUSE.getValue()))
+        // 仅当任务启用时才注册到 Quartz;停用/暂停的任务不注册,避免残留 trigger 导致不停打印跳过日志
+        if (!ScheduleConstants.Status.PAUSE.getValue().equals(job.getStatus()))
         {
-            scheduler.pauseJob(ScheduleUtils.getJobKey(jobId, jobGroup));
+            scheduler.scheduleJob(jobDetail, trigger);
         }
     }
 

+ 16 - 0
fs-task/src/main/java/com/fs/quartz/util/ScopedJobExecutor.java

@@ -0,0 +1,16 @@
+package com.fs.quartz.util;
+
+import com.fs.quartz.domain.SysJob;
+import org.quartz.JobExecutionContext;
+
+/**
+ * 定时任务业务执行回调。
+ * <p>
+ * 由 {@link MultiScopeJobDispatcher} 在已切换好的数据源上下文中调用,
+ * 典型实现为 {@link JobInvokeUtil#invokeMethod(SysJob)} 反射执行业务 Bean 方法。
+ */
+@FunctionalInterface
+public interface ScopedJobExecutor {
+
+    void execute(JobExecutionContext context, SysJob sysJob) throws Exception;
+}

+ 0 - 114
fs-task/src/main/java/com/fs/quartz/util/TenantJobDispatcherJob.java

@@ -1,114 +0,0 @@
-//package com.fs.quartz.util;
-//
-//import com.fs.common.config.RedisTenantContext;
-//import com.fs.common.core.domain.model.TenantPrincipal;
-//import com.fs.common.enums.DataSourceType;
-//import com.fs.common.utils.CronUtils;
-//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.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 org.slf4j.Logger;
-//import org.slf4j.LoggerFactory;
-//import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
-//import org.springframework.security.core.context.SecurityContextHolder;
-//import org.quartz.Job;
-//import org.quartz.JobExecutionContext;
-//import org.quartz.JobExecutionException;
-//
-//import java.util.Collections;
-//import java.util.Date;
-//import java.util.List;
-//import java.util.stream.Collectors;
-//
-///**
-// * SaaS tenant job dispatcher: load active tenants from master, switch DB, run due sys_job in tenant library.
-// */
-//public class TenantJobDispatcherJob implements Job {
-//
-//    private static final Logger log = LoggerFactory.getLogger(TenantJobDispatcherJob.class);
-//
-//    @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 {
-//            DynamicDataSourceContextHolder.setDataSourceType(DataSourceType.MASTER.name());
-//            TenantInfo query = new TenantInfo();
-//            query.setStatus(1);
-//            List<TenantInfo> tenants = tenantInfoService.selectTenantInfoList(query);
-//            if (tenants == null || tenants.isEmpty()) {
-//                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] tenant tenantId={}, tenantCode={} dispatch error",
-//                            tenant.getId(), tenant.getTenantCode(), e);
-//                } finally {
-//                    ProjectConfig.clearTenantConfigs();
-//                    TenantConfigContext.clear();
-//                    RedisTenantContext.clear();
-//                    SecurityContextHolder.clearContext();
-//                    DynamicDataSourceContextHolder.clearDataSourceType();
-//                }
-//            }
-//        } finally {
-//            DynamicDataSourceContextHolder.clearDataSourceType();
-//        }
-//    }
-//
-//    private void dispatchForTenant(TenantInfo tenant, TenantDataSourceManager tenantDataSourceManager,
-//                                   SysJobMapper sysJobMapper, SysConfigMapper sysConfigMapper) throws Exception {
-//        tenantDataSourceManager.switchTenant(tenant);
-//        RedisTenantContext.setTenantId(tenant.getId());
-//        SecurityContextHolder.getContext().setAuthentication(
-//                new UsernamePasswordAuthenticationToken(
-//                        new TenantPrincipal(tenant.getId()), null, Collections.emptyList()));
-//
-//        SysConfig cfg = sysConfigMapper.selectConfigByConfigKey("projectConfig");
-//        ProjectConfig.safeLoadTenantConfigFromValue(cfg != null ? cfg.getConfigValue() : null);
-//
-//        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) {
-//            String taskName = job.getJobName() != null ? job.getJobName() : job.getInvokeTarget();
-//            log.info("[SaaS Quartz] dataSource=tenant:{}, tenantId={}, tenantCode={}, task={}",
-//                    tenant.getId(), tenant.getId(), tenant.getTenantCode(), taskName);
-//            try {
-//                if (!JobInvokeUtil.isInvokeTargetAvailable(job)) {
-//                    log.debug("[SaaS Quartz] tenantId={} skip jobId={}, bean unavailable: {}",
-//                            tenant.getId(), job.getJobId(), job.getInvokeTarget());
-//                    continue;
-//                }
-//                JobInvokeUtil.invokeMethod(job);
-//            } catch (Exception e) {
-//                log.error("[SaaS Quartz] tenantId={} jobId={}, invokeTarget={} failed",
-//                        tenant.getId(), job.getJobId(), job.getInvokeTarget(), e);
-//            }
-//        }
-//    }
-//}

+ 3 - 8
fs-task/src/main/java/com/fs/task/jobs/QwTask.java

@@ -20,7 +20,6 @@ import com.fs.sop.service.impl.QwSopServiceImpl;
 import com.fs.sop.vo.QwSopLogsDoSendListTVO;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Value;
-import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
 
 import java.time.LocalDate;
@@ -46,8 +45,8 @@ import org.slf4j.LoggerFactory;
  * - 涵盖 SOP 规则执行、标签/群聊/客户同步、外部联系人变更处理、AI 分析触发等。
  *
  * SaaS 注意:
- * - 部分方法内部会根据 saas.task.enabled 和 RedisTenantContext 决定是否跨租户执行(由 TenantTaskRunner 包装)
- * - 作为 fs-task 提供的 Task Bean,由 fs-admin 的中央调度器在每个租户上下文中按时触发
+ * - Quartz 入口不统一切租户;需要跨租户时在方法内通过 TenantTaskRunner 自行遍历
+ * - 方法内若使用 @Async 或自建线程池,子线程须通过 TenantTaskContextHelper 传入 tenantId 再切库,或改为同步执行
  *
  * 典型调用示例(sys_job.invoke_target):
  *   qwTask.sopTask()
@@ -55,7 +54,7 @@ import org.slf4j.LoggerFactory;
  *
  * @author 系统
  */
-@Component
+@Component("qwTask")
 public class QwTask {
 
     private static final Logger log = LoggerFactory.getLogger(QwTask.class);
@@ -166,7 +165,6 @@ public class QwTask {
      *
      * @throws Exception 执行异常
      */
-    @Async
     public void selectSopUserLogsListByTime() throws Exception {
         LocalDateTime currentTime = LocalDateTime.now().withMinute(0).withSecond(0).withNano(0);
         log.info("任务实际执行时间: {}", currentTime);
@@ -419,7 +417,6 @@ public class QwTask {
     /**
      * 凌晨 2点35开始,将营期小于7天中标记为 是否7天未看课的(E级) 客户的 但是看课了的恢复一下
      */
-    @Async
     public void processSopUserLogsInfoByIsDaysNotStudy() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 是否7天未看课的(E级) 客户的 恢复一下 ======");
@@ -437,7 +434,6 @@ public class QwTask {
      * 执行时间:每天凌晨 3:45:00
      * 功能:对SOP营期用户进行分级评级
      */
-    @Async
     public void processQwSopExternalContactRatingTimer() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 sop营期-用户分级 ======");
@@ -453,7 +449,6 @@ public class QwTask {
     /**
      * 凌晨4点35开始 客户超过7天没有看课的 标记E级
      */
-    @Async
     public void processQwSopExternalContactRatingMoreSevenDaysTimer() {
         long startTimeMillis = System.currentTimeMillis();
         log.info("====== 开始选择和处理 sop营期-用户超7天的看课情况 ======");

+ 35 - 7
fs-task/src/main/java/com/fs/task/support/impl/SopLogsChatTaskServiceImpl.java

@@ -2,6 +2,8 @@ package com.fs.task.support.impl;
 
 import cn.hutool.json.JSONUtil;
 import com.alibaba.fastjson.JSON;
+import com.fs.common.config.RedisTenantContext;
+import com.fs.quartz.support.TenantTaskContextHelper;
 import com.fs.task.support.SopLogsChatTaskService;
 import com.fs.fastGpt.param.SendHookAIParam;
 import com.fs.qw.domain.QwUser;
@@ -61,6 +63,9 @@ public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
     @Autowired
     RedisTemplate<String, String> redisTemplate;
 
+    @Autowired
+    private TenantTaskContextHelper tenantTaskContextHelper;
+
     /**
      * 查询所有的AIsop任务
      * @throws Exception
@@ -75,8 +80,12 @@ public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
             log.info("没有需要处理的 Ai对话SOP 任务。");
             return;
         }
+
+        // 获取当前租户ID,传递给异步方法以便重新切库
+        Long currentTenantId = RedisTenantContext.getTenantId();
+
         for (QwSop sop : sopByChats) {
-            processAiChatSopAsync(sop, sopLatch,today);
+            processAiChatSopAsync(sop, sopLatch, today, currentTenantId);
         }
 
         // 等待所有 SOP 分组处理完成
@@ -92,9 +101,20 @@ public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
             maxAttempts = 3,
             backoff = @Backoff(delay = 2000)
     )
-    public void processAiChatSopAsync(QwSop sop, CountDownLatch latch,LocalDateTime today) {
+    public void processAiChatSopAsync(QwSop sop, CountDownLatch latch, LocalDateTime today, Long tenantId) {
         try {
-            processAiChatSop(sop,today);
+            if (tenantId != null) {
+                tenantTaskContextHelper.runWithTenant(tenantId, () -> {
+                    try {
+                        processAiChatSop(sop, today, tenantId);
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                    return null;
+                });
+            } else {
+                processAiChatSop(sop, today, tenantId);
+            }
         } catch (Exception e) {
             log.error("处理 SOP ID {} 时发生异常: {}", sop.getId(), e.getMessage(), e);
         } finally {
@@ -106,7 +126,7 @@ public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
      * 查询任务中对应模板
      * @throws Exception
      */
-    private void processAiChatSop(QwSop sop,LocalDateTime today) throws Exception {
+    private void processAiChatSop(QwSop sop, LocalDateTime today, Long tenantId) throws Exception {
         QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sop.getId());
         List<QwSopTempRules> rulesList = qwSopTempRulesService.listByTempId(ruleTimeVO.getTempId());
         if (rulesList.isEmpty()) {
@@ -121,7 +141,7 @@ public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
 
         CountDownLatch userLogsLatch = new CountDownLatch(qwUsers.size());
         for (QwUser qwUser : qwUsers) {
-            processAiChatUserLogInfoAsync(qwUser, ruleTimeVO, rulesList, userLogsLatch,sop,today);
+            processAiChatUserLogInfoAsync(qwUser, ruleTimeVO, rulesList, userLogsLatch, sop, today, tenantId);
         }
 
         // 等待所有用户日志处理完成
@@ -144,9 +164,17 @@ public class SopLogsChatTaskServiceImpl implements SopLogsChatTaskService {
             maxAttempts = 3,
             backoff = @Backoff(delay = 2000)
     )
-    public void processAiChatUserLogInfoAsync(QwUser qwUser, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings, CountDownLatch latch,QwSop sop,LocalDateTime today) {
+    public void processAiChatUserLogInfoAsync(QwUser qwUser, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
+                                              CountDownLatch latch, QwSop sop, LocalDateTime today, Long tenantId) {
         try {
-            processAiChatUserInfoLog(qwUser, tempSettings,today);
+            if (tenantId != null) {
+                tenantTaskContextHelper.runWithTenant(tenantId, () -> {
+                    processAiChatUserInfoLog(qwUser, tempSettings, today);
+                    return null;
+                });
+            } else {
+                processAiChatUserInfoLog(qwUser, tempSettings, today);
+            }
         } catch (Exception e) {
             log.error("处理用户日志 {} 时发生异常: {}", qwUser.getId(), e.getMessage(), e);
         } finally {

+ 39 - 13
fs-task/src/main/java/com/fs/task/support/impl/SopLogsTaskServiceImpl.java

@@ -6,6 +6,7 @@ import com.alibaba.fastjson.JSONArray;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.common.config.RedisTenantContext;
 import com.fs.framework.task.TenantTaskRunner;
+import com.fs.quartz.support.TenantTaskContextHelper;
 import com.fs.task.support.SopLogsTaskService;
 import com.fs.common.config.FSSysConfig;
 import com.fs.common.utils.PubFun;
@@ -190,6 +191,10 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
     @Resource
     private TenantTaskRunner tenantTaskRunner;
+
+    @Autowired
+    private TenantTaskContextHelper tenantTaskContextHelper;
+
     @Value("${saas.task.enabled:false}")
     private boolean saasTaskEnabled;
 
@@ -384,10 +389,13 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         CountDownLatch sopGroupLatch = new CountDownLatch(sopLogsGroupedById.size());
 
+        // 获取当前租户ID,传递给异步方法以便重新切库
+        Long currentTenantId = RedisTenantContext.getTenantId();
+
         for (Map.Entry<String, List<SopUserLogsVo>> entry : sopLogsGroupedById.entrySet()) {
             String sopId = entry.getKey();
             List<SopUserLogsVo> userLogsVos = entry.getValue();
-            processSopGroupAsync(sopId, userLogsVos, sopGroupLatch,currentTime, groupChatMap,config,miniMap,companies);
+            processSopGroupAsync(sopId, userLogsVos, sopGroupLatch,currentTime, groupChatMap,config,miniMap,companies, currentTenantId);
         }
 
         // 等待所有 SOP 分组处理完成
@@ -407,11 +415,22 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
             maxAttempts = 3,
             backoff = @Backoff(delay = 2000)
     )
-    public void processSopGroupAsync(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch ,LocalDateTime currentTime,
-                                     Map<String, QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                     List<Company> companies) {
+    public void processSopGroupAsync(String sopId, List<SopUserLogsVo> userLogsVos, CountDownLatch latch, LocalDateTime currentTime,
+                                     Map<String, QwGroupChat> groupChatMap, CourseConfig config, Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                     List<Company> companies, Long tenantId) {
         try {
-            processSopGroup(sopId, userLogsVos,currentTime, groupChatMap, config,miniMap,companies);
+            if (tenantId != null) {
+                tenantTaskContextHelper.runWithTenant(tenantId, () -> {
+                    try {
+                        processSopGroup(sopId, userLogsVos, currentTime, groupChatMap, config, miniMap, companies, tenantId);
+                    } catch (Exception e) {
+                        throw new RuntimeException(e);
+                    }
+                    return null;
+                });
+            } else {
+                processSopGroup(sopId, userLogsVos, currentTime, groupChatMap, config, miniMap, companies, tenantId);
+            }
         } catch (Exception e) {
             log.error("处理 SOP ID {} 时发生异常: {}", sopId, e.getMessage(), e);
         } finally {
@@ -420,9 +439,9 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     }
 
 
-    private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos,LocalDateTime currentTime, Map<String,
-                                         QwGroupChat> groupChatMap,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                 List<Company> companies) throws Exception {
+    private void processSopGroup(String sopId, List<SopUserLogsVo> userLogsVos, LocalDateTime currentTime, Map<String,
+                                         QwGroupChat> groupChatMap, CourseConfig config, Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                 List<Company> companies, Long tenantId) throws Exception {
         QwSopRuleTimeVO ruleTimeVO = sopMapper.selectQwSopByClickHouseId(sopId);
 
         if (ruleTimeVO == null) {
@@ -464,8 +483,8 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
 
         CountDownLatch userLogsLatch = new CountDownLatch(userLogsVos.size());
         for (SopUserLogsVo logVo : userLogsVos) {
-            processUserLogAsync(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap,qwCompany.getMiniAppId(),
-                    config,miniMap,companies);
+            processUserLogAsync(logVo, ruleTimeVO, rulesList, userLogsLatch, currentTime, groupChatMap, qwCompany.getMiniAppId(),
+                    config, miniMap, companies, tenantId);
         }
 
         // 等待所有用户日志处理完成
@@ -486,10 +505,17 @@ public class SopLogsTaskServiceImpl implements SopLogsTaskService {
     )
     public void processUserLogAsync(SopUserLogsVo logVo, QwSopRuleTimeVO ruleTimeVO, List<QwSopTempRules> tempSettings,
                                     CountDownLatch latch, LocalDateTime currentTime, Map<String, QwGroupChat> groupChatMap,
-                                    String miniAppId,CourseConfig config,Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
-                                    List<Company> companies) {
+                                    String miniAppId, CourseConfig config, Map<Long, Map<Integer, List<CompanyMiniapp>>> miniMap,
+                                    List<Company> companies, Long tenantId) {
         try {
-            processUserLog(logVo, ruleTimeVO, tempSettings,currentTime, groupChatMap, miniAppId, config,miniMap,companies);
+            if (tenantId != null) {
+                tenantTaskContextHelper.runWithTenant(tenantId, () -> {
+                    processUserLog(logVo, ruleTimeVO, tempSettings, currentTime, groupChatMap, miniAppId, config, miniMap, companies);
+                    return null;
+                });
+            } else {
+                processUserLog(logVo, ruleTimeVO, tempSettings, currentTime, groupChatMap, miniAppId, config, miniMap, companies);
+            }
         } catch (Exception e) {
             log.error("处理用户日志 {} 时发生异常: {}", logVo.getId(), e.getMessage(), e);
         } finally {

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

@@ -109,3 +109,8 @@ fs:
   task:
     workers:
       enabled: true
+
+# 仅 fs-task / fs-qw-api 模块启用 Quartz Scheduler(其他模块依赖 fs-quartz 时不会启动)
+saas:
+  scheduler:
+    enabled: true

+ 3 - 0
fs-task/src/main/resources/logback.xml

@@ -51,8 +51,11 @@
 
 	<logger name="com.fs" level="info" />
 	<logger name="org.springframework" level="warn" />
+	<logger name="org.springframework.jdbc" level="warn" />
 	<logger name="com.baomidou.mybatisplus" level="warn" />
 	<logger name="org.apache.ibatis" level="warn" />
+	<!-- 抑制 Quartz/多租户切库场景下「非事务 SqlSession」调试日志刷屏 -->
+	<logger name="org.mybatis.spring" level="warn" />
 
     <root level="info">
 		<appender-ref ref="console" />