多租户定时任务管理-示例源码
约 2836 字大约 9 分钟
2026-04-04
配套 多租户定时任务管理架构.md 的核心代码示例,术语已脱敏可直接参考。
一、ThreadLocal 数据源上下文
package com.example.framework.datasource;
/**
* 数据源上下文持有器,通过 ThreadLocal 存储当前线程的租户标识
*/
public class DynamicDataSourceContextHolder {
private static final ThreadLocal<String> CONTEXT_HOLDER = new ThreadLocal<>();
public static void setDataSourceType(String dsType) {
CONTEXT_HOLDER.set(dsType);
}
public static String getDataSourceType() {
return CONTEXT_HOLDER.get();
}
public static void clearDataSourceType() {
CONTEXT_HOLDER.remove();
}
}二、动态数据源路由
package com.example.framework.datasource;
import java.util.HashMap;
import java.util.Map;
import javax.sql.DataSource;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
/**
* 动态数据源,根据 ThreadLocal 中的租户标识路由到对应连接池
*/
public class DynamicDataSource extends AbstractRoutingDataSource {
private Map<Object, Object> targetDataSourcesMap;
public DynamicDataSource(DataSource defaultTargetDataSource,
Map<Object, Object> targetDataSources) {
super.setDefaultTargetDataSource(defaultTargetDataSource);
this.targetDataSourcesMap = new HashMap<>(targetDataSources);
super.setTargetDataSources(this.targetDataSourcesMap);
super.afterPropertiesSet();
}
@Override
protected Object determineCurrentLookupKey() {
return DynamicDataSourceContextHolder.getDataSourceType();
}
/**
* 运行时动态注册租户数据源
*/
public synchronized void addTargetDataSource(String dsKey, DataSource dataSource) {
this.targetDataSourcesMap.put(dsKey, dataSource);
super.setTargetDataSources(new HashMap<>(this.targetDataSourcesMap));
super.afterPropertiesSet();
}
public boolean containsDataSource(String dsKey) {
return this.targetDataSourcesMap.containsKey(dsKey);
}
}三、HTTP 过滤器(JWT → 租户标识 → ThreadLocal)
package com.example.framework.datasource;
import java.io.IOException;
import javax.servlet.*;
import javax.servlet.http.*;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
/**
* 租户上下文过滤器:从请求 JWT 中提取租户标识并设置数据源路由
*/
@Component
@Order(1)
public class TenantContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
try {
HttpServletRequest httpRequest = (HttpServletRequest) request;
// 1. 提取 token(优先 Authorization 头,其次 Cookie)
String token = extractToken(httpRequest);
if (token == null) {
chain.doFilter(request, response);
return;
}
// 2. 从 JWT 中解析租户标识
String tenantId = parseTenantIdFromJwt(token);
if (tenantId == null) {
chain.doFilter(request, response);
return;
}
// 3. 确保数据源已注册(兜底重试)
ensureDataSourceRegistered(tenantId);
// 4. 设置 ThreadLocal,后续 MyBatis 操作自动路由到租户库
DynamicDataSourceContextHolder.setDataSourceType(tenantId);
chain.doFilter(request, response);
} finally {
// 5. 请求结束后清理,防止线程池复用时的上下文污染
DynamicDataSourceContextHolder.clearDataSourceType();
}
}
private String extractToken(HttpServletRequest request) {
// Authorization: Bearer <token>
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
return auth.substring(7);
}
// Cookie: Admin-Token=<token>
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if ("Admin-Token".equals(c.getName())) {
return c.getValue();
}
}
}
return null;
}
/** 以下为伪代码,实际实现需依赖具体 TokenService 和配置 */
private String parseTenantIdFromJwt(String token) { /* JWT 解码 → tenantId */ return null; }
private void ensureDataSourceRegistered(String tenantId) { /* 兜底注册 */ }
}四、数据源配置属性
package com.example.framework.config.properties;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 多租户数据源配置,映射 application-tenant.yml
*/
@Component
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
/** 默认租户标识 */
private String defaultCode;
/** 租户标识 → 数据源配置 */
private Map<String, TenantDataSourceProperties> datasources = new LinkedHashMap<>();
// getters / setters ...
public static class TenantDataSourceProperties {
private String name; // 租户名称
private String url; // JDBC 连接地址
private String username; // 数据库用户名
private String password; // 数据库密码
// getters / setters ...
}
}五、数据源注册事件
package com.example.framework.event;
import javax.sql.DataSource;
import org.springframework.context.ApplicationEvent;
/**
* 租户数据源注册完成事件,供 Quartz 等模块监听并创建对应的 Scheduler
*/
public class TenantDataSourceRegisteredEvent extends ApplicationEvent {
private final String tenantId;
private final DataSource dataSource;
public TenantDataSourceRegisteredEvent(Object source, String tenantId,
DataSource dataSource) {
super(source);
this.tenantId = tenantId;
this.dataSource = dataSource;
}
public String getTenantId() { return tenantId; }
public DataSource getDataSource() { return dataSource; }
}六、SchedulerManager(核心)
package com.example.quartz.config;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.ConcurrentHashMap;
import javax.sql.DataSource;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationContext;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.quartz.SchedulerFactoryBean;
import org.springframework.stereotype.Component;
/**
* 多租户调度器管理器
*
* 每租户对应一个独立的 Scheduler 实例,各自使用租户库中的 QRTZ_* 表持久化。
* 启动时预初始化所有配置的租户数据源及调度器,确保重启后定时任务立即恢复。
*/
@Component
public class SchedulerManager {
private static final Logger log = LoggerFactory.getLogger(SchedulerManager.class);
/** 租户标识 → Scheduler */
private final Map<String, Scheduler> schedulers = new ConcurrentHashMap<>();
private final ApplicationContext applicationContext;
public SchedulerManager(ApplicationContext applicationContext) {
this.applicationContext = applicationContext;
}
// ======================== 启动预初始化 ========================
/**
* 应用启动完成后,预初始化所有配置的租户调度器。
* 失败跳过,等用户首次登录时兜底重试。
*/
@EventListener(ApplicationReadyEvent.class)
public void initAll() {
TenantProperties props = getBean(TenantProperties.class);
if (props.getDatasources() == null || props.getDatasources().isEmpty()) {
log.info("[SchedulerMgr] 未配置租户数据源,跳过");
return;
}
log.info("[SchedulerMgr] 开始预初始化 {} 个租户调度器", props.getDatasources().size());
for (String tenantId : props.getDatasources().keySet()) {
try {
getBean(DruidConfig.class).registerTenantDataSource(tenantId,
props.getDatasources().get(tenantId));
} catch (Exception e) {
log.warn("[SchedulerMgr] 租户 [{}] 预初始化失败(库不可达),登录时重试: {}",
tenantId, e.getMessage());
}
}
log.info("[SchedulerMgr] 预初始化完成,已注册 {} 个调度器", schedulers.size());
}
// ======================== 事件监听 ========================
/**
* 监听租户数据源注册事件,自动创建对应的 Quartz Scheduler
*/
@EventListener
public void onTenantDataSourceRegistered(TenantDataSourceRegisteredEvent event) {
try {
registerScheduler(event.getTenantId(), event.getDataSource());
} catch (SchedulerException e) {
log.error("[SchedulerMgr] 自动注册调度器失败: {}", event.getTenantId(), e);
}
}
// ======================== Scheduler 注册 ========================
/**
* 为指定租户创建并启动一个独立的 Quartz 调度器
*
* @param tenantId 租户标识
* @param dataSource 该租户对应的数据源(直连,非 DynamicDataSource)
*/
public Scheduler registerScheduler(String tenantId, DataSource dataSource)
throws SchedulerException {
// 已存在且未关闭 → 复用
Scheduler existing = schedulers.get(tenantId);
if (existing != null && !existing.isShutdown()) {
log.info("[SchedulerMgr] 租户 [{}] 调度器已存在", tenantId);
return existing;
}
SchedulerFactoryBean factory = new SchedulerFactoryBean();
factory.setDataSource(dataSource);
factory.setSchedulerName("Scheduler_" + tenantId);
factory.setQuartzProperties(buildQuartzProperties(tenantId));
factory.setStartupDelay(1);
factory.setOverwriteExistingJobs(true);
factory.setAutoStartup(true);
// ★ 关键:注入 ApplicationContext,确保 Quartz 工作线程能访问 Spring Bean
factory.setApplicationContext(applicationContext);
try {
factory.afterPropertiesSet();
} catch (Exception e) {
throw new SchedulerException("初始化调度器失败: " + tenantId, e);
}
Scheduler scheduler = factory.getScheduler();
// ★ 双重保障:同时存入 Scheduler 上下文
scheduler.getContext().put("applicationContext", applicationContext);
scheduler.start();
schedulers.put(tenantId, scheduler);
log.info("[SchedulerMgr] 租户 [{}] 调度器已注册并启动, scheduler={}",
tenantId, scheduler.getSchedulerName());
return scheduler;
}
// ======================== 查询方法 ========================
public Scheduler getScheduler(String tenantId) {
return schedulers.get(tenantId);
}
public boolean hasScheduler(String tenantId) {
Scheduler s = schedulers.get(tenantId);
if (s == null) return false;
try { return !s.isShutdown(); }
catch (SchedulerException e) { return false; }
}
// ======================== 内部方法 ========================
private Properties buildQuartzProperties(String tenantId) {
Properties prop = new Properties();
prop.put("org.quartz.scheduler.instanceName", "Scheduler_" + tenantId);
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");
prop.put("org.quartz.jobStore.class",
"org.springframework.scheduling.quartz.LocalDataSourceJobStore");
prop.put("org.quartz.jobStore.isClustered", "false");
prop.put("org.quartz.jobStore.maxMisfiresToHandleAtATime", "1");
prop.put("org.quartz.jobStore.misfireThreshold", "12000");
prop.put("org.quartz.jobStore.tablePrefix", "QRTZ_");
return prop;
}
private <T> T getBean(Class<T> clazz) {
return applicationContext.getBean(clazz);
}
}七、AbstractQuartzJob(跨线程上下文恢复)
package com.example.quartz.util;
import java.util.Date;
import org.quartz.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationContext;
/**
* Quartz Job 抽象基类
*
* 核心职责:
* 1. before() — 从 JobDataMap 恢复租户标识到 ThreadLocal
* 2. doExecute() — 子类实现具体业务
* 3. after() — 写入任务日志 + 清理 ThreadLocal
*/
public abstract class AbstractQuartzJob implements Job {
private static final Logger log = LoggerFactory.getLogger(AbstractQuartzJob.class);
private static final ThreadLocal<Date> START_TIME = new ThreadLocal<>();
// ======================== 模板方法 ========================
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
SysJob sysJob = parseJobFromContext(context);
try {
before(context);
doExecute(context, sysJob);
after(context, sysJob, null);
} catch (Exception e) {
log.error("任务执行异常", e);
after(context, sysJob, e);
}
}
// ======================== 钩子方法 ========================
/**
* 执行前:从 JobDataMap 恢复租户上下文
*/
protected void before(JobExecutionContext context) {
START_TIME.set(new Date());
Object tenantId = context.getMergedJobDataMap().get("TENANT_ID");
if (tenantId != null) {
DynamicDataSourceContextHolder.setDataSourceType(tenantId.toString());
}
}
/**
* 执行后:记录日志 + 清理上下文
*/
protected void after(JobExecutionContext context, SysJob sysJob, Exception e) {
Date startTime = START_TIME.get();
START_TIME.remove();
// 构建日志对象
SysJobLog log = new SysJobLog();
log.setJobName(sysJob.getJobName());
log.setJobGroup(sysJob.getJobGroup());
log.setStartTime(startTime);
log.setStopTime(new Date());
long runMs = log.getStopTime().getTime() - log.getStartTime().getTime();
log.setJobMessage(sysJob.getJobName() + " 总耗时:" + runMs + "ms");
log.setStatus(e == null ? "SUCCESS" : "FAIL");
if (e != null) {
log.setExceptionInfo(truncate(e.getMessage(), 2000));
}
// ★ 写入日志(此时 ThreadLocal 仍持有租户标识,日志入正确的库)
try {
ApplicationContext appCtx = (ApplicationContext)
context.getScheduler().getContext().get("applicationContext");
if (appCtx != null) {
appCtx.getBean(JobLogService.class).addJobLog(log);
}
} catch (SchedulerException ex) {
log.error("获取 Scheduler 上下文失败", ex);
}
// ★ 清理 ThreadLocal,防止线程池复用时的上下文污染
DynamicDataSourceContextHolder.clearDataSourceType();
}
/**
* 子类实现:具体业务逻辑
*/
protected abstract void doExecute(JobExecutionContext context,
SysJob sysJob) throws Exception;
// ======================== 工具方法 ========================
private SysJob parseJobFromContext(JobExecutionContext context) {
SysJob job = new SysJob();
copyProps(context.getMergedJobDataMap().get("TASK_PROPERTIES"), job);
return job;
}
private static String truncate(String s, int maxLen) {
return s != null && s.length() > maxLen ? s.substring(0, maxLen) : s;
}
private static void copyProps(Object src, Object dst) { /* BeanUtils.copyProperties */ }
}八、ScheduleUtils(创建任务 + 存入租户标识)
package com.example.quartz.util;
import org.quartz.*;
/**
* Quartz 任务创建工具
*
* ★ 关键:创建 Job 时将当前线程的租户标识写入 JobDataMap,
* 确保 Quartz 工作线程执行时能恢复正确的数据源上下文。
*/
public class ScheduleUtils {
public static final String TASK_PROPERTIES = "TASK_PROPERTIES";
public static final String TENANT_ID = "TENANT_ID";
/**
* 创建并调度一个 Cron 定时任务
*/
public static void createScheduleJob(Scheduler scheduler, SysJob job)
throws SchedulerException {
// 1. 选择 Job 实现类(并发 / 禁止并发)
Class<? extends Job> jobClass = "0".equals(job.getConcurrent())
? QuartzJobExecution.class // 允许并发
: QuartzDisallowConcurrentExecution.class; // 禁止并发
// 2. 构建 JobDetail
JobDetail jobDetail = JobBuilder.newJob(jobClass)
.withIdentity(jobKey(job.getJobId(), job.getJobGroup()))
.build();
// 3. 构建 CronTrigger
CronTrigger trigger = TriggerBuilder.newTrigger()
.withIdentity(triggerKey(job.getJobId(), job.getJobGroup()))
.withSchedule(CronScheduleBuilder.cronSchedule(job.getCronExpression())
.withMisfireHandlingInstructionDoNothing())
.build();
// 4. ★ 存入任务属性
jobDetail.getJobDataMap().put(TASK_PROPERTIES, job);
// 5. ★★ 存入当前租户标识(跨越 HTTP → Quartz 工作线程的上下文传递)
String currentTenantId = DynamicDataSourceContextHolder.getDataSourceType();
if (currentTenantId != null && !currentTenantId.isEmpty()) {
jobDetail.getJobDataMap().put(TENANT_ID, currentTenantId);
}
// 6. 注册到 Scheduler
if (scheduler.checkExists(jobKey(job.getJobId(), job.getJobGroup()))) {
scheduler.deleteJob(jobKey(job.getJobId(), job.getJobGroup()));
}
scheduler.scheduleJob(jobDetail, trigger);
// 7. 新任务默认暂停,需手动启动
if ("PAUSE".equals(job.getStatus())) {
scheduler.pauseJob(jobKey(job.getJobId(), job.getJobGroup()));
}
}
public static JobKey jobKey(Long jobId, String jobGroup) {
return JobKey.jobKey("TASK_" + jobId, jobGroup);
}
public static TriggerKey triggerKey(Long jobId, String jobGroup) {
return TriggerKey.triggerKey("TASK_" + jobId, jobGroup);
}
}九、SysJobServiceImpl(业务层 — 当前租户 Scheduler 定位)
package com.example.quartz.service.impl;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 定时任务服务实现
*
* ★ 关键:每个操作都通过 currentScheduler() 获取当前租户对应的 Scheduler,
* 保证任务操作作用在正确的租户调度器上。
*/
@Service
public class SysJobServiceImpl {
private final SchedulerManager schedulerManager;
private final SysJobMapper jobMapper;
public SysJobServiceImpl(SchedulerManager schedulerManager,
SysJobMapper jobMapper) {
this.schedulerManager = schedulerManager;
this.jobMapper = jobMapper;
}
/**
* ★ 获取当前租户的 Scheduler
* 从 ThreadLocal 读取租户标识 → 从 SchedulerManager 获取对应实例
*/
private Scheduler currentScheduler() throws SchedulerException {
String tenantId = DynamicDataSourceContextHolder.getDataSourceType();
if (tenantId == null || tenantId.isEmpty()) {
throw new SchedulerException("当前线程未设置数据源上下文");
}
Scheduler scheduler = schedulerManager.getScheduler(tenantId);
if (scheduler == null) {
throw new SchedulerException("租户 [" + tenantId + "] 调度器未注册");
}
return scheduler;
}
@Transactional
public int insertJob(SysJob job) throws Exception {
job.setStatus("PAUSE"); // 新任务默认暂停
int rows = jobMapper.insertJob(job); // 写入当前租户库的 sys_job 表
if (rows > 0) {
ScheduleUtils.createScheduleJob(currentScheduler(), job);
}
return rows;
}
@Transactional
public int pauseJob(SysJob job) throws SchedulerException {
job.setStatus("PAUSE");
int rows = jobMapper.updateJob(job);
if (rows > 0) {
currentScheduler().pauseJob(ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup()));
}
return rows;
}
@Transactional
public int resumeJob(SysJob job) throws SchedulerException {
job.setStatus("NORMAL");
int rows = jobMapper.updateJob(job);
if (rows > 0) {
currentScheduler().resumeJob(ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup()));
}
return rows;
}
@Transactional
public int deleteJob(SysJob job) throws SchedulerException {
int rows = jobMapper.deleteJobById(job.getJobId());
if (rows > 0) {
currentScheduler().deleteJob(ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup()));
}
return rows;
}
/** 立即执行一次 */
public void runOnce(SysJob job) throws SchedulerException {
Scheduler scheduler = currentScheduler();
JobKey jobKey = ScheduleUtils.jobKey(job.getJobId(), job.getJobGroup());
// 如果任务尚未注册到 Scheduler,先创建
if (!scheduler.checkExists(jobKey)) {
ScheduleUtils.createScheduleJob(scheduler, job);
}
// 构建参数(包含租户标识,确保工作线程能恢复上下文)
JobDataMap dataMap = new JobDataMap();
dataMap.put(ScheduleUtils.TASK_PROPERTIES, job);
String tenantId = DynamicDataSourceContextHolder.getDataSourceType();
if (tenantId != null) {
dataMap.put(ScheduleUtils.TENANT_ID, tenantId);
}
scheduler.triggerJob(jobKey, dataMap);
}
}十、DruidConfig(数据源创建 + 动态注册)
package com.example.framework.config;
import com.alibaba.druid.pool.DruidDataSource;
import javax.sql.DataSource;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
/**
* 多数据源配置
*
* 启动时创建 DynamicDataSource(含主库),
* 运行时通过 registerTenantDataSource() 动态注册租户数据源。
*/
@Configuration
public class DruidConfig {
private final ApplicationEventPublisher eventPublisher;
public DruidConfig(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
/**
* 动态数据源(主 Bean,@Primary)
*/
@Bean
@Primary
public DynamicDataSource dataSource(DataSource masterDataSource) {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put("MASTER", masterDataSource);
return new DynamicDataSource(masterDataSource, targetDataSources);
}
/**
* 运行时动态注册租户数据源
*
* 流程:创建 Druid 连接池 → 注册到 DynamicDataSource → 发布事件 → SchedulerManager 创建调度器
*/
public void registerTenantDataSource(String tenantId,
TenantProperties.TenantDataSourceProperties config) {
DynamicDataSource dynamicDS = getBean(DynamicDataSource.class);
// 已注册则跳过
if (dynamicDS.containsDataSource(tenantId)) {
return;
}
// 创建独立的 Druid 连接池
DruidDataSource ds = new DruidDataSource();
ds.setUrl(config.getUrl());
ds.setUsername(config.getUsername());
ds.setPassword(config.getPassword());
ds.setInitialSize(1);
ds.setMinIdle(2);
ds.setMaxActive(10);
ds.setMaxWait(60000);
ds.setValidationQuery("SELECT 1");
ds.setTestWhileIdle(true);
// 初始化连接池
try {
ds.init();
} catch (Exception e) {
throw new RuntimeException("创建租户数据源失败: " + tenantId, e);
}
// 注册到动态数据源
dynamicDS.addTargetDataSource(tenantId, ds);
// ★ 发布事件,通知 SchedulerManager 创建对应的 Quartz 调度器
eventPublisher.publishEvent(
new TenantDataSourceRegisteredEvent(this, tenantId, ds));
}
private <T> T getBean(Class<T> clazz) {
return SpringUtils.getBean(clazz);
}
}十一、配置文件
application-tenant.yml
# 多租户数据源配置
tenant:
default: tenant-a
datasources:
T001:
name: 租户一
url: jdbc:mysql://your-db-host:3306/tenant_db_1?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
username: root
password: xxxxxx
T002:
name: 租户二
url: jdbc:mysql://your-db-host:3306/tenant_db_2?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8
username: root
password: xxxxxxapplication.yml(关键配置)
spring:
devtools:
restart:
enabled: false # ★ 禁用热部署,避免 Quartz ClassLoader 冲突
mybatis:
mapper-locations: classpath*:mapper/**/*Mapper.xml十二、数据流全景
┌─ HTTP Thread ─────────────────────────────────────────────┐
│ 1. TenantContextFilter │
│ token → tenantId → DynamicDataSourceContextHolder.set() │
│ │
│ 2. SysJobServiceImpl.insertJob() │
│ ├─ jobMapper.insert() // MyBatis → 租户库 │
│ └─ currentScheduler() // ThreadLocal → 租户Scheduler │
│ │
│ 3. ScheduleUtils.createScheduleJob() │
│ └─ JobDataMap.put("TENANT_ID", tenantId) // ★存入 │
└────────────────────────────────────────────────────────────┘
│
▼ (Quartz Worker Thread)
┌─ Quartz Thread ───────────────────────────────────────────┐
│ 4. AbstractQuartzJob.before() │
│ └─ JobDataMap.get("TENANT_ID") → ThreadLocal.set() ★恢复│
│ │
│ 5. doExecute() → 反射调用业务 Bean │
│ └─ MyBatis → 自动路由到正确的租户库 │
│ │
│ 6. AbstractQuartzJob.after() │
│ ├─ ISysJobLogService.addJobLog() // 日志入租户库 │
│ └─ DynamicDataSourceContextHolder.clear() ★清理 │
└────────────────────────────────────────────────────────────┘贡献者
更新日志
2026/7/21 17:13
查看所有更新日志
b8db2-更新多租户定时任务集成配置于