Procházet zdrojové kódy

fix(s8): gate scheduler jobs with safe env overrides

- Add Scheduler:Enabled / S8:Scheduler:Enabled / S8:Scheduler:WatchTickIntervalMs
  to WatchScheduler.json (defaults: env on, S8 off, tick 300000ms).
- Dev override anchors S8:Scheduler:Enabled=false to prevent accidental
  auto-scheduling in development.
- Three S8 jobs gate at ExecuteAsync entry with single-fire disabled log
  and AIDOP_SCHEDULER_ENABLED / AIDOP_S8_SCHEDULER_ENABLED env overrides
  (Furion JSON loader is positioned after the ASP.NET Core env provider,
  so AIDOP_* env vars are read directly via Environment.GetEnvironmentVariable,
  matching the SqlSugarSetup AIDOP_* convention).
- S8WatchSchedulerJob: AIDOP_S8_SCHEDULER_WATCH_TICK_MS env override and
  in-process 5-min due-skip on top of the 60s hardware tick; empty-tick
  summary (all six counters zero) downgraded from LogInformation to LogDebug.
- Non-parsable env values keep the JSON value and emit a single LogWarning
  with the env name only (no raw value).

Bump csproj 1.0.133 -> 1.0.134.
YY968XX před 1 dnem
rodič
revize
c39ef4607d

+ 9 - 0
server/Admin.NET.Application/Configuration/WatchScheduler.Development.json

@@ -5,5 +5,14 @@
   // 不被部署到生产环境的发布产物中(生产仍走 WatchScheduler.json 的默认 false)。
   // 不被部署到生产环境的发布产物中(生产仍走 WatchScheduler.json 的默认 false)。
   "WatchScheduler": {
   "WatchScheduler": {
     "DebugEndpointEnabled": true
     "DebugEndpointEnabled": true
+  },
+
+  // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:开发环境显式覆写 S8 调度开关为 false。
+  // 与 WatchScheduler.json 默认值一致,仅用于显式锚定 dev 默认关闭语义,
+  // 防止后续基础配置改动导致 dev 环境意外开启 S8 自动调度。
+  "S8": {
+    "Scheduler": {
+      "Enabled": false
+    }
   }
   }
 }
 }

+ 19 - 0
server/Admin.NET.Application/Configuration/WatchScheduler.json

@@ -8,5 +8,24 @@
     // 调试入口 POST /api/aidop/s8/watch-debug/run-once 启用开关。
     // 调试入口 POST /api/aidop/s8/watch-debug/run-once 启用开关。
     // 默认 false(生产安全);dev/test 通过 appsettings.Development.json 覆写为 true。
     // 默认 false(生产安全);dev/test 通过 appsettings.Development.json 覆写为 true。
     "DebugEndpointEnabled": false
     "DebugEndpointEnabled": false
+  },
+
+  // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:环境级调度总开关。
+  // 默认 true 保持既有行为;生产侧紧急止血可通过部署环境变量
+  // Scheduler__Enabled=false 强制关闭所有 S8 Job 执行入口。
+  "Scheduler": {
+    "Enabled": true
+  },
+
+  // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:S8 业务调度开关 + 主调度业务节拍。
+  // Enabled 默认 false:开发环境与未显式开启的部署一律不执行 S8 Job 业务体。
+  //   生产开启路径仅保留为部署侧环境变量 S8__Scheduler__Enabled=true。
+  // WatchTickIntervalMs 默认 300000 (5 分钟):S8WatchSchedulerJob 业务执行节拍;
+  //   硬件唤醒拍仍由 [Period(60000, ...)] 注解承载,不在本文件配置。
+  "S8": {
+    "Scheduler": {
+      "Enabled": false,
+      "WatchTickIntervalMs": 300000
+    }
   }
   }
 }
 }

+ 3 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.133</AssemblyVersion>
-    <FileVersion>1.0.133</FileVersion>
-    <Version>1.0.133</Version>
+    <AssemblyVersion>1.0.134</AssemblyVersion>
+    <FileVersion>1.0.134</FileVersion>
+    <Version>1.0.134</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 53 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8ActiveFlowStuckScanJob.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Furion.Schedule;
 using Furion.Schedule;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
@@ -14,16 +15,67 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 public class S8ActiveFlowStuckScanJob : IJob
 public class S8ActiveFlowStuckScanJob : IJob
 {
 {
     private readonly IServiceScopeFactory _scopeFactory;
     private readonly IServiceScopeFactory _scopeFactory;
+    private readonly IConfiguration _configuration;
     private readonly ILogger _logger;
     private readonly ILogger _logger;
 
 
-    public S8ActiveFlowStuckScanJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:禁用日志单进程内只输出一次,避免每 tick 刷日志。
+    // _firstParseFailLogged 控制 AIDOP_* env 解析失败 warn 单进程内只输出一次(首个失败 env 名)。
+    private static int _firstDisabledLogged;
+    private static int _firstParseFailLogged;
+
+    // S8-SCHEDULER-CONFIG-ORDER-FIX(合入 P0):AIDOP_* env override 名称。
+    // Furion 4.9.8.24 JSON 装配链后置导致 ASP.NET Core env vars 被 JSON 覆盖,
+    // 故此处用 Environment.GetEnvironmentVariable 做 S8 调度开关专项 override。
+    private const string EnvSchedulerEnabled = "AIDOP_SCHEDULER_ENABLED";
+    private const string EnvS8SchedulerEnabled = "AIDOP_S8_SCHEDULER_ENABLED";
+
+    public S8ActiveFlowStuckScanJob(IServiceScopeFactory scopeFactory, IConfiguration configuration, ILoggerFactory loggerFactory)
     {
     {
         _scopeFactory = scopeFactory;
         _scopeFactory = scopeFactory;
+        _configuration = configuration;
         _logger = loggerFactory.CreateLogger(nameof(S8ActiveFlowStuckScanJob));
         _logger = loggerFactory.CreateLogger(nameof(S8ActiveFlowStuckScanJob));
     }
     }
 
 
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     {
     {
+        // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:环境级 + S8 业务级双开关 gate。
+        // 任一为 false 时直接早退;早退前不访问任何业务 service / DB。
+        // 注意:[Period(... RunOnStart = true)] 在启动期会立即触发一次,此处 gate 同样生效,
+        // 确保 dev/未授权环境启动时不会立刻进入 ScanAsync。
+        // env override(合入 CONFIG-ORDER-FIX):env 不存在/空白 → 沿用 JSON;
+        // 存在但 bool.TryParse 失败 → 保留 JSON 值 + 单次 warn(首个失败 env 名,不输出 value)。
+        var schedulerEnabled = _configuration.GetValue("Scheduler:Enabled", true);
+        var s8SchedulerEnabled = _configuration.GetValue("S8:Scheduler:Enabled", false);
+        string? parseFailEnvName = null;
+        var envSchedulerRaw = Environment.GetEnvironmentVariable(EnvSchedulerEnabled);
+        if (!string.IsNullOrWhiteSpace(envSchedulerRaw))
+        {
+            if (bool.TryParse(envSchedulerRaw, out var v)) schedulerEnabled = v;
+            else parseFailEnvName ??= EnvSchedulerEnabled;
+        }
+        var envS8SchedulerRaw = Environment.GetEnvironmentVariable(EnvS8SchedulerEnabled);
+        if (!string.IsNullOrWhiteSpace(envS8SchedulerRaw))
+        {
+            if (bool.TryParse(envS8SchedulerRaw, out var v)) s8SchedulerEnabled = v;
+            else parseFailEnvName ??= EnvS8SchedulerEnabled;
+        }
+        if (parseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
+        {
+            _logger.LogWarning(
+                "S8ActiveFlowStuckScanJob env override 解析失败:{EnvName},已沿用配置值",
+                parseFailEnvName);
+        }
+        if (!schedulerEnabled || !s8SchedulerEnabled)
+        {
+            if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
+            {
+                _logger.LogInformation(
+                    "S8ActiveFlowStuckScanJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}",
+                    schedulerEnabled, s8SchedulerEnabled);
+            }
+            return;
+        }
+
         using var scope = _scopeFactory.CreateScope();
         using var scope = _scopeFactory.CreateScope();
         var watchService = scope.ServiceProvider.GetRequiredService<S8ActiveFlowWatchService>();
         var watchService = scope.ServiceProvider.GetRequiredService<S8ActiveFlowWatchService>();
 
 

+ 51 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8TimeoutAutoEscalationJob.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Furion.Schedule;
 using Furion.Schedule;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 
 
@@ -17,16 +18,65 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 public class S8TimeoutAutoEscalationJob : IJob
 public class S8TimeoutAutoEscalationJob : IJob
 {
 {
     private readonly IServiceScopeFactory _scopeFactory;
     private readonly IServiceScopeFactory _scopeFactory;
+    private readonly IConfiguration _configuration;
     private readonly ILogger _logger;
     private readonly ILogger _logger;
 
 
-    public S8TimeoutAutoEscalationJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:禁用日志单进程内只输出一次,避免每 tick 刷日志。
+    // _firstParseFailLogged 控制 AIDOP_* env 解析失败 warn 单进程内只输出一次(首个失败 env 名)。
+    private static int _firstDisabledLogged;
+    private static int _firstParseFailLogged;
+
+    // S8-SCHEDULER-CONFIG-ORDER-FIX(合入 P0):AIDOP_* env override 名称。
+    // Furion 4.9.8.24 JSON 装配链后置导致 ASP.NET Core env vars 被 JSON 覆盖,
+    // 故此处用 Environment.GetEnvironmentVariable 做 S8 调度开关专项 override。
+    private const string EnvSchedulerEnabled = "AIDOP_SCHEDULER_ENABLED";
+    private const string EnvS8SchedulerEnabled = "AIDOP_S8_SCHEDULER_ENABLED";
+
+    public S8TimeoutAutoEscalationJob(IServiceScopeFactory scopeFactory, IConfiguration configuration, ILoggerFactory loggerFactory)
     {
     {
         _scopeFactory = scopeFactory;
         _scopeFactory = scopeFactory;
+        _configuration = configuration;
         _logger = loggerFactory.CreateLogger("S8TimeoutAutoEscalationJob");
         _logger = loggerFactory.CreateLogger("S8TimeoutAutoEscalationJob");
     }
     }
 
 
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     {
     {
+        // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:环境级 + S8 业务级双开关 gate。
+        // 任一为 false 时直接早退;早退前不访问任何业务 service / DB。
+        // env override(合入 CONFIG-ORDER-FIX):env 不存在/空白 → 沿用 JSON;
+        // 存在但 bool.TryParse 失败 → 保留 JSON 值 + 单次 warn(首个失败 env 名,不输出 value)。
+        var schedulerEnabled = _configuration.GetValue("Scheduler:Enabled", true);
+        var s8SchedulerEnabled = _configuration.GetValue("S8:Scheduler:Enabled", false);
+        string? parseFailEnvName = null;
+        var envSchedulerRaw = Environment.GetEnvironmentVariable(EnvSchedulerEnabled);
+        if (!string.IsNullOrWhiteSpace(envSchedulerRaw))
+        {
+            if (bool.TryParse(envSchedulerRaw, out var v)) schedulerEnabled = v;
+            else parseFailEnvName ??= EnvSchedulerEnabled;
+        }
+        var envS8SchedulerRaw = Environment.GetEnvironmentVariable(EnvS8SchedulerEnabled);
+        if (!string.IsNullOrWhiteSpace(envS8SchedulerRaw))
+        {
+            if (bool.TryParse(envS8SchedulerRaw, out var v)) s8SchedulerEnabled = v;
+            else parseFailEnvName ??= EnvS8SchedulerEnabled;
+        }
+        if (parseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
+        {
+            _logger.LogWarning(
+                "S8TimeoutAutoEscalationJob env override 解析失败:{EnvName},已沿用配置值",
+                parseFailEnvName);
+        }
+        if (!schedulerEnabled || !s8SchedulerEnabled)
+        {
+            if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
+            {
+                _logger.LogInformation(
+                    "S8TimeoutAutoEscalationJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}",
+                    schedulerEnabled, s8SchedulerEnabled);
+            }
+            return;
+        }
+
         using var scope = _scopeFactory.CreateScope();
         using var scope = _scopeFactory.CreateScope();
         var svc = scope.ServiceProvider.GetRequiredService<S8TimeoutAutoEscalationService>();
         var svc = scope.ServiceProvider.GetRequiredService<S8TimeoutAutoEscalationService>();
         try
         try

+ 114 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -1,5 +1,6 @@
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Admin.NET.Plugin.AiDOP.Service.S8;
 using Furion.Schedule;
 using Furion.Schedule;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using System.Text.Json;
 using System.Text.Json;
@@ -19,12 +20,19 @@ namespace Admin.NET.Plugin.AiDOP.Job;
 public class S8WatchSchedulerJob : IJob
 public class S8WatchSchedulerJob : IJob
 {
 {
     private readonly IServiceScopeFactory _scopeFactory;
     private readonly IServiceScopeFactory _scopeFactory;
+    private readonly IConfiguration _configuration;
     private readonly ILogger _logger;
     private readonly ILogger _logger;
 
 
     // S8-SCHED-EXEC-1:节拍 5min → 1min;单规则节奏由 watch_rule.poll_interval_seconds + next_run_at 控制,
     // S8-SCHED-EXEC-1:节拍 5min → 1min;单规则节奏由 watch_rule.poll_interval_seconds + next_run_at 控制,
     // Job 仅做 tick;未到期规则在 PickReadyRulesAsync 阶段自然过滤。
     // Job 仅做 tick;未到期规则在 PickReadyRulesAsync 阶段自然过滤。
     public const int IntervalMs = 60000;
     public const int IntervalMs = 60000;
 
 
+    // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:业务执行节拍配置项与下限。
+    // 业务节拍由 appsettings 的 S8:Scheduler:WatchTickIntervalMs 承载,默认 300000 ms (5 分钟);
+    // 硬件唤醒拍由 [Period(60000, ...)] 注解承载;下限 60000 ms 与硬件拍对齐,配置小于下限按下限处理。
+    private const int DefaultWatchTickIntervalMs = 300000;
+    private const int MinWatchTickIntervalMs = 60000;
+
     // 默认租户 / 工厂上下文。与 G-01 / S8-SCHED-SCHEMA-1 验收口径一致。
     // 默认租户 / 工厂上下文。与 G-01 / S8-SCHED-SCHEMA-1 验收口径一致。
     private const long DefaultTenantId = 1;
     private const long DefaultTenantId = 1;
     private const long DefaultFactoryId = 1;
     private const long DefaultFactoryId = 1;
@@ -35,21 +43,106 @@ public class S8WatchSchedulerJob : IJob
     // 首次激活打印锚点。
     // 首次激活打印锚点。
     private static int _firstActivationLogged;
     private static int _firstActivationLogged;
 
 
+    // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:禁用提示锚点 + 业务节拍 due-skip 锚点。
+    // _firstDisabledLogged 控制禁用日志单进程内只输出一次,避免每 tick 刷日志。
+    // _lastBusinessTickUtcTicks 记录上一次进入业务体(RunDispatchTickAsync)时的 UTC ticks,
+    // 用于"硬件拍 60s + 业务拍 5min"模式下的 due-skip 判定。
+    // _firstParseFailLogged 控制 AIDOP_* env 解析失败 warn 单进程内只输出一次(首个失败 env 名)。
+    private static int _firstDisabledLogged;
+    private static long _lastBusinessTickUtcTicks;
+    private static int _firstParseFailLogged;
+
+    // S8-SCHEDULER-CONFIG-ORDER-FIX(合入 P0):AIDOP_* env override 名称。
+    // Furion 4.9.8.24 把 Configuration/*.{Environment}.json 追加在 ASP.NET Core
+    // EnvironmentVariablesConfigurationProvider 之后,JSON 覆盖了 env,故此处用
+    // Environment.GetEnvironmentVariable 做 S8 调度开关专项 override;
+    // 命名前缀 AIDOP_ 与 SqlSugarSetup.cs 既有约定(AIDOP_DB_WAIT_MAX_SECONDS 等)一致。
+    private const string EnvSchedulerEnabled = "AIDOP_SCHEDULER_ENABLED";
+    private const string EnvS8SchedulerEnabled = "AIDOP_S8_SCHEDULER_ENABLED";
+    private const string EnvS8WatchTickMs = "AIDOP_S8_SCHEDULER_WATCH_TICK_MS";
+
     public S8WatchSchedulerJob(
     public S8WatchSchedulerJob(
         IServiceScopeFactory scopeFactory,
         IServiceScopeFactory scopeFactory,
+        IConfiguration configuration,
         ILoggerFactory loggerFactory)
         ILoggerFactory loggerFactory)
     {
     {
         _scopeFactory = scopeFactory;
         _scopeFactory = scopeFactory;
+        _configuration = configuration;
         _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
         _logger = loggerFactory.CreateLogger(nameof(S8WatchSchedulerJob));
     }
     }
 
 
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
     {
     {
+        // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:环境级 + S8 业务级双开关 gate。
+        // 任一为 false 时直接早退;早退前不访问任何业务 service / DB。
+        // env override(合入 CONFIG-ORDER-FIX):env 不存在/空白 → 沿用 JSON;
+        // 存在但 bool.TryParse 失败 → 保留 JSON 值 + 单次 warn(首个失败 env 名,不输出 value)。
+        var schedulerEnabled = _configuration.GetValue("Scheduler:Enabled", true);
+        var s8SchedulerEnabled = _configuration.GetValue("S8:Scheduler:Enabled", false);
+        string? parseFailEnvName = null;
+        var envSchedulerRaw = Environment.GetEnvironmentVariable(EnvSchedulerEnabled);
+        if (!string.IsNullOrWhiteSpace(envSchedulerRaw))
+        {
+            if (bool.TryParse(envSchedulerRaw, out var v)) schedulerEnabled = v;
+            else parseFailEnvName ??= EnvSchedulerEnabled;
+        }
+        var envS8SchedulerRaw = Environment.GetEnvironmentVariable(EnvS8SchedulerEnabled);
+        if (!string.IsNullOrWhiteSpace(envS8SchedulerRaw))
+        {
+            if (bool.TryParse(envS8SchedulerRaw, out var v)) s8SchedulerEnabled = v;
+            else parseFailEnvName ??= EnvS8SchedulerEnabled;
+        }
+        if (parseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
+        {
+            _logger.LogWarning(
+                "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值",
+                parseFailEnvName);
+        }
+        if (!schedulerEnabled || !s8SchedulerEnabled)
+        {
+            if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
+            {
+                _logger.LogInformation(
+                    "S8WatchSchedulerJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}",
+                    schedulerEnabled, s8SchedulerEnabled);
+            }
+            return;
+        }
+
+        // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:业务节拍 due-skip。
+        // 硬件唤醒拍仍由 [Period(60000, ...)] 维持,但业务体只在配置的 WatchTickIntervalMs 周期上执行。
+        // 首次启用时 _lastBusinessTickUtcTicks==0,立即放行;后续 tick 不足 interval 时早退。
+        // 早退前不调用 S8WatchSchedulerService、不查询/写入数据库。
+        // env override(合入 CONFIG-ORDER-FIX):env 存在且 int.TryParse 成功 → 覆盖 JSON;
+        // 解析失败 → 保留 JSON 值 + 单次 warn(与 bool 解析失败共用 _firstParseFailLogged 锚点)。
+        // clamp 规则与 P0 一致:小于 MinWatchTickIntervalMs(60000)时强制按下限处理。
+        var tickIntervalMs = _configuration.GetValue("S8:Scheduler:WatchTickIntervalMs", DefaultWatchTickIntervalMs);
+        var envTickRaw = Environment.GetEnvironmentVariable(EnvS8WatchTickMs);
+        if (!string.IsNullOrWhiteSpace(envTickRaw))
+        {
+            if (int.TryParse(envTickRaw, out var v)) tickIntervalMs = v;
+            else if (Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
+            {
+                _logger.LogWarning(
+                    "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值",
+                    EnvS8WatchTickMs);
+            }
+        }
+        if (tickIntervalMs < MinWatchTickIntervalMs) tickIntervalMs = MinWatchTickIntervalMs;
+        var nowUtcTicks = DateTime.UtcNow.Ticks;
+        var lastTicks = Interlocked.Read(ref _lastBusinessTickUtcTicks);
+        if (lastTicks != 0)
+        {
+            var elapsedMs = (nowUtcTicks - lastTicks) / TimeSpan.TicksPerMillisecond;
+            if (elapsedMs < tickIntervalMs) return;
+        }
+        Interlocked.Exchange(ref _lastBusinessTickUtcTicks, nowUtcTicks);
+
         if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
         if (Interlocked.Exchange(ref _firstActivationLogged, 1) == 0)
         {
         {
             _logger.LogInformation(
             _logger.LogInformation(
-                "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} BatchSize={Batch} TenantId={Tenant} FactoryId={Factory} LockedBy={LockedBy}",
-                IntervalMs, BatchSize, DefaultTenantId, DefaultFactoryId, BuildLockedBy());
+                "S8WatchSchedulerJob 首次激活:IntervalMs={IntervalMs} TickIntervalMs={TickInterval} BatchSize={Batch} TenantId={Tenant} FactoryId={Factory} LockedBy={LockedBy}",
+                IntervalMs, tickIntervalMs, BatchSize, DefaultTenantId, DefaultFactoryId, BuildLockedBy());
         }
         }
 
 
         var triggeredAt = DateTime.Now;
         var triggeredAt = DateTime.Now;
@@ -60,7 +153,12 @@ public class S8WatchSchedulerJob : IJob
         {
         {
             var lockedBy = BuildLockedBy();
             var lockedBy = BuildLockedBy();
             var summary = await scheduler.RunDispatchTickAsync(DefaultTenantId, DefaultFactoryId, BatchSize, lockedBy);
             var summary = await scheduler.RunDispatchTickAsync(DefaultTenantId, DefaultFactoryId, BatchSize, lockedBy);
-            _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null));
+            // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑(6 项摘要全 0)降为 LogDebug;
+            // 任何非零字段(picked/failed/perRuleFailed/created/refreshed/leaseReleased)保持 LogInformation。
+            if (IsEmptyTick(summary))
+                _logger.LogDebug(BuildTraceLine(triggeredAt, "success", summary, null));
+            else
+                _logger.LogInformation(BuildTraceLine(triggeredAt, "success", summary, null));
         }
         }
         catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
         catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
         {
         {
@@ -116,4 +214,17 @@ public class S8WatchSchedulerJob : IJob
         var oneLine = raw.Replace('\n', ' ').Replace('\r', ' ').Trim();
         var oneLine = raw.Replace('\n', ' ').Replace('\r', ' ').Trim();
         return oneLine.Length <= 200 ? oneLine : oneLine[..200];
         return oneLine.Length <= 200 ? oneLine : oneLine[..200];
     }
     }
+
+    // S8-SCHEDULER-P0-BLEEDING-STOP-CONFIG-1:完全空跑判定。
+    // 6 项摘要全 0 → 视为本 tick 无任何业务变化,日志降级到 Debug。
+    private static bool IsEmptyTick(S8DispatchTickResult? summary)
+    {
+        if (summary == null) return true;
+        return summary.Picked == 0
+            && summary.Failed == 0
+            && summary.PerRuleFailed == 0
+            && summary.Created == 0
+            && summary.Refreshed == 0
+            && summary.LeaseReleased == 0;
+    }
 }
 }