using dy.net.job; using dy.net.model.dto; using dy.net.model.entity; using dy.net.utils; using Quartz; using Serilog; namespace dy.net.service { /// /// 抖音相关定时任务服务 /// public class DouyinQuartzJobService { private readonly ISchedulerFactory _schedulerFactory; private readonly DouyinCookieService douyinCookieService; private readonly DouyinCommonService _commonService; private readonly VideoTaskService _videoTasks; private const string DefaultJobGroup = "dysync.net"; private const int DefaultIntervalMinutes = 30; private const int DefaultCronStartDelaySeconds = 30; private const int DefaultSimpleStartDelaySeconds = 3; // 任务配置信息(修复了series任务的Key重复问题,确保每个任务Key唯一) public static Dictionary JobConfigs { get; } = new() { { VideoTypeEnum.dy_collects, new JobConfig( typeof(DouyinCollectSyncJob), "dy.job.key.collect", "dy.trigger.key.collect", "抖音收藏同步任务") }, { VideoTypeEnum.dy_favorite, new JobConfig( typeof(DouyinFavoritSyncJob), "dy.job.key.favorite", "dy.trigger.key.favorite", "抖音点赞同步任务") }, { VideoTypeEnum.dy_follows, new JobConfig( typeof(DouyinFollowedSyncJob), "dy.job.key.followed", "dy.trigger.key.followed", "抖音关注博主作品同步任务") }, { VideoTypeEnum.dy_followuser, new JobConfig( typeof(DouyinFollowsAndCollnectsSyncJob), "dy.job.key.follow_user", "dy.trigger.key.follow_user", "抖音关注列表同步任务") }, { VideoTypeEnum.dy_custom_collect, new JobConfig( typeof(DouyinCollectCustomSyncJob), "dy.job.key.custom_collect", "dy.trigger.key.custom_collect", "抖音自定义收藏夹列表同步任务") }, { VideoTypeEnum.dy_mix, new JobConfig( typeof(DouyinMixSyncJob), "dy.job.key.mix", "dy.trigger.key.mix", "抖音收藏夹合集同步任务") }, { VideoTypeEnum.dy_series, new JobConfig( typeof(DouyinSeriesSyncJob), "dy.job.key.series", "dy.trigger.key.series", "抖音收藏夹短剧同步任务") }, { VideoTypeEnum.dy_followuser_once, new JobConfig( typeof(DouyinFollowsAndCollnectsSyncJob), "dy.job.key.sync_follow_user_once", "dy.trigger.key.sync_follow_user_once", "抖音关注同步任务(单次执行)") }, { VideoTypeEnum.dy_live_monitor, new JobConfig( typeof(DouyinLiveStatusJob), "dy.job.key.live_monitor", "dy.trigger.key.live_monitor", "抖音博主直播状态监测任务") } }; public DouyinQuartzJobService( ISchedulerFactory schedulerFactory, DouyinCookieService douyinCookieService, DouyinCommonService commonService, VideoTaskService videoTasks) { _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory)); this.douyinCookieService = douyinCookieService; _commonService = commonService; _videoTasks = videoTasks; } public async Task> TriggerVideoJobsNowAsync(VideoTypeEnum? requestedType = null) { var cookies = await douyinCookieService.GetOpendCookiesAsync(); if (cookies == null || cookies.Count == 0) throw new InvalidOperationException("没有已启用的抖音授权账号。"); var storageType = _commonService.GetConfig()?.StorageType ?? StorageType.Local; var enabled = GetTaskEnableConditions(cookies, storageType); var types = new List(); if (enabled.IsFavoriteEnabled) types.Add(VideoTypeEnum.dy_favorite); if (enabled.IsCollectEnabled) types.Add(VideoTypeEnum.dy_collects); if (enabled.IsFollowedEnabled) types.Add(VideoTypeEnum.dy_follows); if (enabled.IsCustomCollectEnabled) types.Add(VideoTypeEnum.dy_custom_collect); if (enabled.IsMixEnabled) types.Add(VideoTypeEnum.dy_mix); if (enabled.IsSeriesEnabled) types.Add(VideoTypeEnum.dy_series); if (types.Count == 0) throw new InvalidOperationException("没有已启用且路径完整的视频同步类型。"); if (requestedType.HasValue) { if (!IsVideoSyncType(requestedType.Value)) throw new InvalidOperationException("指定的任务类型不是可手动执行的视频同步类型。"); if (!types.Contains(requestedType.Value)) { var message = requestedType.Value == VideoTypeEnum.dy_follows ? "关注视频同步未启用:请检查账号的关注下载开关、当前存储的关注路径,以及是否有博主开启同步。" : $"{requestedType.Value.GetDesc()}同步未启用或当前存储路径未配置。"; throw new InvalidOperationException(message); } types = new List { requestedType.Value }; } var scheduler = await _schedulerFactory.GetScheduler(); var created = new List(); foreach (var type in types) { var config = JobConfigs[type]; var jobKey = new JobKey(config.JobKey, DefaultJobGroup); if (!await scheduler.CheckExists(jobKey)) await StartJobAsync(type, (_commonService.GetConfig()?.Cron ?? DefaultIntervalMinutes).ToString()); var task = await _videoTasks.CreateTaskAsync(VideoTaskType.Sync, VideoTaskTrigger.Manual, $"{type.GetDesc()}手动同步", type, storageType); try { await scheduler.TriggerJob(jobKey, new JobDataMap { ["video-task-id"] = task.Id, ["video-task-trigger"] = "manual" }); } catch (Exception ex) { var message = $"{type.GetDesc()}手动同步未能进入调度,请稍后重试。"; await _videoTasks.CompleteTaskAsync(task.Id, message); Log.Error(ex, "手动同步任务调度失败:{VideoType}, TaskId={TaskId}", type, task.Id); throw new InvalidOperationException(message, ex); } created.Add(task); } return created; } internal static bool IsVideoSyncType(VideoTypeEnum type) => type is VideoTypeEnum.dy_favorite or VideoTypeEnum.dy_collects or VideoTypeEnum.dy_follows or VideoTypeEnum.dy_custom_collect or VideoTypeEnum.dy_mix or VideoTypeEnum.dy_series; /// /// 初始化或重启所有抖音定时任务 /// /// 定时任务表达式(分钟数) /// 是否成功初始化 public async Task InitOrReStartAllJobs(string cronExpression) { try { // 1. 获取并验证Cookie var validCookies = await douyinCookieService.GetOpendCookiesAsync(); if (validCookies == null || !validCookies.Any()) { Serilog.Log.Debug("没有有效的抖音Cookie,无法启动定时任务"); return false; } // 2. 处理定时任务表达式 var taskIntervalExpression = ResolveTaskExpression(cronExpression); // 3. 获取调度器并清理现有任务 var scheduler = await _schedulerFactory.GetScheduler(); if (scheduler == null) { Log.Error("获取任务调度器失败,无法初始化定时任务"); return false; } await RemoveAllExistingJobs(scheduler); // 4. 检查各类型任务的启用条件 var storageType = _commonService.GetConfig()?.StorageType ?? StorageType.Local; var taskEnableConditions = GetTaskEnableConditions(validCookies, storageType); // 5. 启动符合条件的定时任务 int successfullyStartedJobs = 0; foreach (var jobKey in JobConfigs.Keys) { // 跳过一次性关注用户任务 if (jobKey == VideoTypeEnum.dy_followuser_once) continue; // 处理关注用户任务(固定60分钟执行频率) if (jobKey == VideoTypeEnum.dy_followuser) { bool startSuccess = await StartSingleJobAsync(jobKey, "60"); if (startSuccess) successfullyStartedJobs++; continue; } // 直播监测完全独立于视频同步,固定每5分钟检查已单独开启的博主。 if (jobKey == VideoTypeEnum.dy_live_monitor) { bool startSuccess = await StartSingleJobAsync(jobKey, "5"); if (startSuccess) successfullyStartedJobs++; continue; } // 根据不同任务类型和启用条件启动任务 bool isTaskEnabled = jobKey switch { VideoTypeEnum.dy_favorite => taskEnableConditions.IsFavoriteEnabled, VideoTypeEnum.dy_collects => taskEnableConditions.IsCollectEnabled, VideoTypeEnum.dy_follows => taskEnableConditions.IsFollowedEnabled, VideoTypeEnum.dy_custom_collect => taskEnableConditions.IsCustomCollectEnabled, VideoTypeEnum.dy_mix => taskEnableConditions.IsMixEnabled, VideoTypeEnum.dy_series => taskEnableConditions.IsSeriesEnabled, _ => false }; if (isTaskEnabled) { bool startSuccess = await StartSingleJobAsync(jobKey, taskIntervalExpression); if (startSuccess) successfullyStartedJobs++; } } // 6. 输出任务启动统计日志 Log.Information($"定时任务初始化完成,共尝试启动 {JobConfigs.Count - 1} 个任务,成功启动 {successfullyStartedJobs} 个"); return true; } catch (Exception ex) { Log.Error(ex, "【quartz】初始化所有抖音定时任务时发生异常"); return false; } } /// /// 解析任务执行表达式,为空时使用默认值 /// /// 输入的表达式 /// 处理后的表达式 private static string ResolveTaskExpression(string inputExpression) { if (string.IsNullOrWhiteSpace(inputExpression)) { Log.Debug("定时任务表达式为空,使用默认配置({DefaultMinutes}分钟)", DefaultIntervalMinutes); return DefaultIntervalMinutes.ToString(); } return inputExpression; } /// /// 获取各类型任务的启用条件 /// /// 有效的抖音Cookie列表 /// 任务启用条件集合 private static TaskEnableConditions GetTaskEnableConditions(IEnumerable cookies, StorageType storageType) { bool HasPath(DouyinCookie cookie, VideoTypeEnum type) { if (storageType.IsRemote()) { return type switch { VideoTypeEnum.dy_favorite => !string.IsNullOrWhiteSpace(cookie.WebDavFavoritePath), VideoTypeEnum.dy_follows => !string.IsNullOrWhiteSpace(cookie.WebDavFollowPath), VideoTypeEnum.dy_mix => !string.IsNullOrWhiteSpace(cookie.WebDavMixPath), VideoTypeEnum.dy_series => !string.IsNullOrWhiteSpace(cookie.WebDavSeriesPath), _ => !string.IsNullOrWhiteSpace(cookie.WebDavCollectPath) }; } return type switch { VideoTypeEnum.dy_favorite => !string.IsNullOrWhiteSpace(cookie.FavSavePath), VideoTypeEnum.dy_follows => !string.IsNullOrWhiteSpace(cookie.UpSavePath), VideoTypeEnum.dy_mix => !string.IsNullOrWhiteSpace(cookie.MixPath) || !string.IsNullOrWhiteSpace(cookie.SavePath), VideoTypeEnum.dy_series => !string.IsNullOrWhiteSpace(cookie.SeriesPath) || !string.IsNullOrWhiteSpace(cookie.SavePath), _ => !string.IsNullOrWhiteSpace(cookie.SavePath) }; } return new TaskEnableConditions { IsCollectEnabled = cookies.Any(x => x.DownCollect && !x.UseCollectFolder && HasPath(x, VideoTypeEnum.dy_collects)), IsFavoriteEnabled = cookies.Any(x => x.DownFavorite && HasPath(x, VideoTypeEnum.dy_favorite)), IsFollowedEnabled = cookies.Any(x => x.DownFollowd && HasPath(x, VideoTypeEnum.dy_follows)), IsMixEnabled = cookies.Any(x => x.DownMix && HasPath(x, VideoTypeEnum.dy_mix)), IsSeriesEnabled = cookies.Any(x => x.DownSeries && HasPath(x, VideoTypeEnum.dy_series)), IsCustomCollectEnabled = cookies.Any(x => x.UseCollectFolder && HasPath(x, VideoTypeEnum.dy_custom_collect)) }; } /// /// 启动单个定时任务(封装重复的启动逻辑) /// /// 任务类型 /// 执行频率表达式 /// 是否启动成功 private async Task StartSingleJobAsync(VideoTypeEnum jobKey, string expression) { try { bool startSuccess = await StartJobAsync(jobKey, expression); if (startSuccess) { Log.Debug($"【quartz】成功启动任务:{jobKey},执行频率:{expression}分钟"); } else { Log.Error($"【quartz】启动任务失败:{jobKey}"); } return startSuccess; } catch (Exception ex) { Log.Error(ex, $"【quartz】启动任务 {jobKey} 时发生异常"); return false; } } /// /// 启动关注同步任务(单次执行) /// public async Task StartFollowJobOnceAsync() { return await StartOneTimeJobAsync(VideoTypeEnum.dy_followuser_once); } /// /// 移除所有已存在的任务(避免重复调度) /// private static async Task RemoveAllExistingJobs(IScheduler scheduler) { var jobKeys = JobConfigs.Values.Select(config => new JobKey(config.JobKey, DefaultJobGroup)).ToList(); foreach (var jobKey in jobKeys) { if (await scheduler.CheckExists(jobKey)) { //Log.Debug("【quartz】移除已存在的任务: {JobKey}", jobKey); await scheduler.DeleteJob(jobKey); } } } /// /// 启动指定定时任务(独立执行,无依赖触发) /// /// 任务配置Key(如:collect、favorite) /// 定时表达式(Cron或间隔分钟数) /// 是否启动成功 public async Task StartJobAsync(VideoTypeEnum configKey, string expression) { if (!JobConfigs.TryGetValue(configKey, out var jobConfig)) { Log.Error("【quartz】找不到任务配置: {ConfigKey}", configKey); return false; } try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup); var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup); // 移除已存在的任务(防止重复执行) await RemoveExistingJobAsync(scheduler, jobKey); // 创建任务详情(保留禁止并发执行,避免同一任务重复运行) var jobDetail = JobBuilder.Create(jobConfig.JobType) .WithIdentity(jobKey) .WithDescription(jobConfig.Description) .DisallowConcurrentExecution() // 禁止同一任务并发执行 .Build(); // 创建定时触发器(仅使用定时触发,移除依赖触发逻辑) ITrigger trigger = CreateScheduledTrigger(triggerKey, expression, jobConfig.Description); // 调度任务 await scheduler.ScheduleJob(jobDetail, trigger); Log.Information("【quartz】启动任务成功 - 任务描述: {JobDescription}, 执行频率: {Expression}", jobConfig.Description, expression); return true; } catch (Exception ex) { Log.Error(ex, "【quartz】启动任务失败 - 任务描述: {JobDescription}", jobConfig.Description); return false; } } /// /// 启动单次执行任务 /// private async Task StartOneTimeJobAsync(VideoTypeEnum configKey) { if (!JobConfigs.TryGetValue(configKey, out var jobConfig)) { Log.Error("【quartz】找不到任务配置: {ConfigKey}", configKey); return false; } try { var scheduler = await _schedulerFactory.GetScheduler(); var jobKey = new JobKey(jobConfig.JobKey, DefaultJobGroup); var triggerKey = new TriggerKey(jobConfig.TriggerKey, DefaultJobGroup); await RemoveExistingJobAsync(scheduler, jobKey); var jobDetail = JobBuilder.Create(jobConfig.JobType) .WithIdentity(jobKey) .WithDescription(jobConfig.Description) .DisallowConcurrentExecution() .Build(); var trigger = TriggerBuilder.Create() .WithIdentity(triggerKey) .WithDescription($"{jobConfig.Description} - 单次执行") .StartNow() .Build(); await scheduler.ScheduleJob(jobDetail, trigger); Log.Debug("【quartz】启动单次任务成功 - 任务描述: {JobDescription}", jobConfig.Description); return true; } catch (Exception ex) { Log.Error(ex, "【quartz】启动单次任务失败 - 任务描述: {JobDescription}", jobConfig.Description); return false; } } /// /// 创建定时触发器(支持Cron表达式或分钟间隔) /// private static ITrigger CreateScheduledTrigger(TriggerKey triggerKey, string expression, string jobDescription) { // Cron表达式格式 if (CronExpression.IsValidExpression(expression)) { return TriggerBuilder.Create() .WithIdentity(triggerKey) .WithDescription($"{jobDescription} - Cron调度") .WithCronSchedule(expression) .StartAt(DateTime.Now.AddSeconds(DefaultCronStartDelaySeconds)) .Build(); } // 数字间隔格式(分钟) if (int.TryParse(expression, out int intervalMinutes)) { intervalMinutes = Math.Max(1, intervalMinutes); // 最小间隔1分钟 return TriggerBuilder.Create() .WithIdentity(triggerKey) .WithDescription($"{jobDescription} - 间隔{intervalMinutes}分钟调度") .StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds)) .WithSimpleSchedule(x => x .WithIntervalInMinutes(intervalMinutes) .RepeatForever()) .Build(); } // 无效表达式,使用默认配置 //Log.Debug("【任务服务】无效的任务表达式: {Expression},使用默认间隔{DefaultMinutes}分钟", // expression, DefaultIntervalMinutes); return TriggerBuilder.Create() .WithIdentity(triggerKey) .WithDescription($"{jobDescription} - 默认间隔调度") .StartAt(DateTime.Now.AddSeconds(DefaultSimpleStartDelaySeconds)) .WithSimpleSchedule(x => x .WithIntervalInMinutes(DefaultIntervalMinutes) .RepeatForever()) .Build(); } /// /// 移除已存在的任务 /// private static async Task RemoveExistingJobAsync(IScheduler scheduler, JobKey jobKey) { if (await scheduler.CheckExists(jobKey)) { //Log.Debug("【quartz】移除已存在的任务: {JobKey}", jobKey); await scheduler.DeleteJob(jobKey); } } /// /// 任务启用条件模型 /// private class TaskEnableConditions { public bool IsCollectEnabled { get; set; } public bool IsFavoriteEnabled { get; set; } public bool IsFollowedEnabled { get; set; } public bool IsMixEnabled { get; set; } public bool IsSeriesEnabled { get; set; } public bool IsCustomCollectEnabled { get; set; } } } }