Просмотр исходного кода

fix(s8): isolate scheduler job enable switches

YY968XX 6 дней назад
Родитель
Сommit
cdff406618

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

@@ -30,6 +30,27 @@
     "Scheduler": {
       "Enabled": false,
       "WatchTickIntervalMs": 300000
+    },
+
+    // S8-P0-2-PER-JOB-KILL-SWITCH-1:单 Job 开关。生效语义为三级与:
+    //   Scheduler:Enabled(环境级,默认 true)
+    //   && S8:Scheduler:Enabled(S8 业务级 master kill switch,默认 false)
+    //   && S8:{Job}:Enabled(本节,默认 false)
+    // 任一为 false,对应 Job 在 ExecuteAsync 第一步即 return,不访问任何 service / DB;
+    // 即便 [Period(..., RunOnStart = true)](ActiveFlowStuckScan)在启动瞬间触发也同样早退
+    // —— 已由 2026-09-03 的 Case B / Case D 运行态实测确认。
+    // 因此**不再需要、也不得依赖**「先暂停 trigger 再重启」作为隔离手段(该手段实测跨重启不可靠)。
+    // 各 env override:AIDOP_S8_WATCH_SCHEDULER_ENABLED /
+    //                  AIDOP_S8_TIMEOUT_AUTO_ESCALATION_ENABLED /
+    //                  AIDOP_S8_ACTIVE_FLOW_STUCK_SCAN_ENABLED
+    "WatchScheduler": {
+      "Enabled": false
+    },
+    "TimeoutAutoEscalation": {
+      "Enabled": false
+    },
+    "ActiveFlowStuckScan": {
+      "Enabled": false
     }
   }
 }

+ 195 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/S8JobSwitchTests.cs

@@ -0,0 +1,195 @@
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Admin.NET.Plugin.AiDOP.Job;
+using Microsoft.Extensions.Configuration;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8;
+
+/// <summary>
+/// S8-P0-2-PER-JOB-KILL-SWITCH-1:三个 S8 后台 Job 的独立开关。
+///
+/// <para>修复前三个 Job 读**完全相同**的两个键,「只跑 Watch」在配置层不可表达。
+/// 2026-09-02 受控启动时打开总开关同时放行了另外两个 Job,其中
+/// <c>ActiveFlowStuckScan</c>(<c>RunOnStart = true</c>)在重启瞬间写入 499 行告警日志;
+/// 当时唯一的隔离手段「先暂停 trigger 再重启」实测**跨重启不可靠**(两次结果相反)。</para>
+///
+/// <para>本测试用真实 <see cref="IConfiguration"/> 覆盖原 Prompt §九 的四组合真值表。
+/// 这是 Gate 2 的代码侧证据;runtime 侧(重启后第一拍)留待统一窗口。</para>
+/// </summary>
+public class S8JobSwitchTests
+{
+    private static IConfiguration Config(bool? environmentEnabled, bool master, bool watch, bool timeout, bool stuck)
+    {
+        var dict = new Dictionary<string, string?>
+        {
+            ["S8:Scheduler:Enabled"] = master.ToString(),
+            ["S8:WatchScheduler:Enabled"] = watch.ToString(),
+            ["S8:TimeoutAutoEscalation:Enabled"] = timeout.ToString(),
+            ["S8:ActiveFlowStuckScan:Enabled"] = stuck.ToString(),
+        };
+        if (environmentEnabled.HasValue) dict["Scheduler:Enabled"] = environmentEnabled.Value.ToString();
+        return new ConfigurationBuilder().AddInMemoryCollection(dict).Build();
+    }
+
+    private static (bool Watch, bool Timeout, bool Stuck) Evaluate(IConfiguration cfg) => (
+        S8JobSwitch.Evaluate(cfg, S8BackgroundJob.WatchScheduler).Enabled,
+        S8JobSwitch.Evaluate(cfg, S8BackgroundJob.TimeoutAutoEscalation).Enabled,
+        S8JobSwitch.Evaluate(cfg, S8BackgroundJob.ActiveFlowStuckScan).Enabled);
+
+    // ───────────────────────── 原 Prompt §九 四组合 ─────────────────────────
+
+    /// <summary>Case A:Master=false,三个 Job 各自 true → 三个都不运行。</summary>
+    [Fact]
+    public void CaseA_MasterOff_DisablesAllThreeJobs()
+    {
+        var (watch, timeout, stuck) = Evaluate(Config(true, master: false, watch: true, timeout: true, stuck: true));
+        Assert.False(watch);
+        Assert.False(timeout);
+        Assert.False(stuck);
+    }
+
+    /// <summary>Case B:Master=true,只有 Watch=true → 只有 Watch 运行。</summary>
+    [Fact]
+    public void CaseB_OnlyWatchScheduler()
+    {
+        var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: true, timeout: false, stuck: false));
+        Assert.True(watch);
+        Assert.False(timeout);
+        Assert.False(stuck);
+    }
+
+    /// <summary>Case C:Master=true,只有 Timeout=true → 只有 Timeout 运行。</summary>
+    [Fact]
+    public void CaseC_OnlyTimeoutAutoEscalation()
+    {
+        var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: true, stuck: false));
+        Assert.False(watch);
+        Assert.True(timeout);
+        Assert.False(stuck);
+    }
+
+    /// <summary>Case D:Master=true,只有 Stuck=true → 只有 StuckScan 运行。</summary>
+    [Fact]
+    public void CaseD_OnlyActiveFlowStuckScan()
+    {
+        var (watch, timeout, stuck) = Evaluate(Config(true, master: true, watch: false, timeout: false, stuck: true));
+        Assert.False(watch);
+        Assert.False(timeout);
+        Assert.True(stuck);
+    }
+
+    // ───────────────────────── 边界与默认 ─────────────────────────
+
+    /// <summary>环境级 master kill switch 优先于一切:Scheduler:Enabled=false → 全停。</summary>
+    [Fact]
+    public void EnvironmentKillSwitch_OverridesEverything()
+    {
+        var (watch, timeout, stuck) = Evaluate(Config(false, master: true, watch: true, timeout: true, stuck: true));
+        Assert.False(watch);
+        Assert.False(timeout);
+        Assert.False(stuck);
+    }
+
+    /// <summary>
+    /// 单 Job 键缺省时必须为 false(fail-safe):只配 Master=true 不足以让任何 Job 跑起来。
+    /// 宁可「本该跑却没跑」也不要「本不该跑却跑了」。
+    /// </summary>
+    [Fact]
+    public void MissingPerJobKey_DefaultsToDisabled()
+    {
+        var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
+        {
+            ["Scheduler:Enabled"] = "true",
+            ["S8:Scheduler:Enabled"] = "true",
+        }).Build();
+
+        var (watch, timeout, stuck) = Evaluate(cfg);
+        Assert.False(watch);
+        Assert.False(timeout);
+        Assert.False(stuck);
+    }
+
+    /// <summary>环境级键缺省为 true,保持既有部署语义不变。</summary>
+    [Fact]
+    public void MissingEnvironmentKey_DefaultsToEnabled()
+    {
+        var d = S8JobSwitch.Evaluate(
+            Config(null, master: true, watch: true, timeout: false, stuck: false),
+            S8BackgroundJob.WatchScheduler);
+        Assert.True(d.EnvironmentEnabled);
+        Assert.True(d.Enabled);
+    }
+
+    /// <summary>三级与的每一级都要能单独否决。</summary>
+    [Theory]
+    [InlineData(false, true, true, false)]
+    [InlineData(true, false, true, false)]
+    [InlineData(true, true, false, false)]
+    [InlineData(true, true, true, true)]
+    public void EffectiveIsConjunctionOfThreeLevels(bool env, bool master, bool own, bool expected)
+    {
+        var d = new S8JobSwitchDecision(env, master, own, null);
+        Assert.Equal(expected, d.Enabled);
+    }
+
+    /// <summary>键名与 env 名固化,防止后续改名导致部署侧静默失配。</summary>
+    [Theory]
+    [InlineData(S8BackgroundJob.WatchScheduler, "S8:WatchScheduler:Enabled", "AIDOP_S8_WATCH_SCHEDULER_ENABLED")]
+    [InlineData(S8BackgroundJob.TimeoutAutoEscalation, "S8:TimeoutAutoEscalation:Enabled", "AIDOP_S8_TIMEOUT_AUTO_ESCALATION_ENABLED")]
+    [InlineData(S8BackgroundJob.ActiveFlowStuckScan, "S8:ActiveFlowStuckScan:Enabled", "AIDOP_S8_ACTIVE_FLOW_STUCK_SCAN_ENABLED")]
+    public void ConfigKeysAndEnvNamesArePinned(S8BackgroundJob job, string key, string env)
+    {
+        Assert.Equal(key, S8JobSwitch.JobEnabledKey(job));
+        Assert.Equal(env, S8JobSwitch.JobEnvName(job));
+    }
+
+    /// <summary>env 解析失败必须保留 JSON 值并回报 env 名(不吞、不静默改判)。</summary>
+    [Fact]
+    public void UnparsableEnvOverride_KeepsJsonValueAndReportsName()
+    {
+        var name = S8JobSwitch.JobEnvName(S8BackgroundJob.WatchScheduler);
+        var original = Environment.GetEnvironmentVariable(name);
+        try
+        {
+            Environment.SetEnvironmentVariable(name, "not-a-bool");
+            var d = S8JobSwitch.Evaluate(
+                Config(true, master: true, watch: true, timeout: false, stuck: false),
+                S8BackgroundJob.WatchScheduler);
+
+            Assert.True(d.JobEnabled);              // 沿用 JSON 的 true
+            Assert.Equal(name, d.ParseFailEnvName); // 但必须回报解析失败
+        }
+        finally { Environment.SetEnvironmentVariable(name, original); }
+    }
+
+    // ───────────────────────── RunOnStart 硬门 ─────────────────────────
+
+    /// <summary>
+    /// <c>ActiveFlowStuckScan</c> 带 <c>RunOnStart = true</c>,启动瞬间必然触发一次;
+    /// 因此三个 Job 的 <c>ExecuteAsync</c> 都必须**先求值开关再做任何事**。
+    /// 断言:门禁调用出现在方法体内、且早于任何 <c>CreateScope()</c>(即拿 service 之前)。
+    /// </summary>
+    [Theory]
+    [InlineData(typeof(S8WatchSchedulerJob), "Job/S8WatchSchedulerJob.cs")]
+    [InlineData(typeof(S8TimeoutAutoEscalationJob), "Job/S8TimeoutAutoEscalationJob.cs")]
+    [InlineData(typeof(S8ActiveFlowStuckScanJob), "Job/S8ActiveFlowStuckScanJob.cs")]
+    public void Gate_IsEvaluatedBeforeAnyServiceResolution(Type jobType, string relativePath)
+    {
+        _ = jobType;
+        var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
+        var full = Path.Combine(root, relativePath);
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+
+        var src = File.ReadAllText(full);
+        var gateIdx = src.IndexOf("S8JobSwitch.Evaluate(_configuration", StringComparison.Ordinal);
+        var scopeIdx = src.IndexOf("_scopeFactory.CreateScope()", StringComparison.Ordinal);
+
+        Assert.True(gateIdx > 0, "未找到 S8JobSwitch 门禁调用");
+        Assert.True(scopeIdx > 0, "未找到 CreateScope(Job 结构可能已变)");
+        Assert.True(gateIdx < scopeIdx, "开关求值必须早于任何 service 解析 / 业务扫描");
+
+        // 旧的双键写法必须彻底消失,避免有人只改一个 Job 造成三处漂移。
+        Assert.DoesNotContain("GetValue(\"S8:Scheduler:Enabled\"", src);
+        Assert.DoesNotContain("GetValue(\"Scheduler:Enabled\"", src);
+    }
+}

+ 126 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Infrastructure/S8/S8JobSwitch.cs

@@ -0,0 +1,126 @@
+using Microsoft.Extensions.Configuration;
+
+namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+
+/// <summary>S8 后台自动任务标识。每个 Job 一个独立开关节。</summary>
+public enum S8BackgroundJob
+{
+    /// <summary>S8 自动监控主链调度(<c>S8:WatchScheduler:Enabled</c>)。</summary>
+    WatchScheduler,
+
+    /// <summary>SLA 超时自动升级(<c>S8:TimeoutAutoEscalation:Enabled</c>)。</summary>
+    TimeoutAutoEscalation,
+
+    /// <summary>ActiveFlow 卡死扫描(<c>S8:ActiveFlowStuckScan:Enabled</c>)。</summary>
+    ActiveFlowStuckScan,
+}
+
+/// <summary>
+/// S8-P0-2-PER-JOB-KILL-SWITCH-1:S8 后台任务三级开关求值。
+///
+/// <para><b>为什么需要</b>:修复前三个 Job(WatchScheduler / TimeoutAutoEscalation / ActiveFlowStuckScan)
+/// 读的是**完全相同**的两个键 <c>Scheduler:Enabled</c> + <c>S8:Scheduler:Enabled</c>,
+/// 没有任何 per-job 键。于是「只想跑 Watch」在配置层不可表达 —— 2026-09-02 的受控启动里,
+/// 打开总开关同时放行了另外两个 Job,其中 <c>ActiveFlowStuckScan</c> 因带 <c>RunOnStart = true</c>
+/// 在重启瞬间就写入了 499 行告警日志。当时只能靠「先暂停 trigger 再重启」隔离,
+/// 而该手段实测**不可靠**(两次重启结果相反:一次暂停态丢失、一次存活)。</para>
+///
+/// <para><b>生效语义</b>(三级与,任一 false 即停):</para>
+/// <code>
+/// Effective = Scheduler:Enabled          // 环境级 master kill switch,默认 true
+///          &amp;&amp; S8:Scheduler:Enabled       // S8 业务级 master kill switch,默认 false
+///          &amp;&amp; S8:{Job}:Enabled           // 单 Job 开关,默认 false
+/// </code>
+///
+/// <para><b>默认 false 是刻意的</b>:新增 per-job 键后,任何未显式声明该键的部署都保持不执行,
+/// 宁可「本该跑却没跑」也不要「本不该跑却跑了」——本模块的事故类型全部属于后者。</para>
+///
+/// <para><b>env override</b>:Furion 4.9.8.24 把 <c>Configuration/*.json</c> 装配在 ASP.NET Core
+/// EnvironmentVariables provider **之后**,JSON 会盖掉 env,故所有开关都改用
+/// <see cref="Environment.GetEnvironmentVariable"/> 直读,命名沿用既有 <c>AIDOP_</c> 前缀约定。
+/// env 不存在 / 空白 → 沿用 JSON;存在但 <c>bool.TryParse</c> 失败 → 保留 JSON 值并回报
+/// <see cref="S8JobSwitchDecision.ParseFailEnvName"/>(由调用方做单次 warn,不输出 value)。</para>
+///
+/// <para><b>与 RunOnStart 的关系</b>:本类只做纯计算、不碰 DB / service。调用方必须在
+/// <c>ExecuteAsync</c> 的**第一步**求值并在 false 时 <c>return</c>,因此即便
+/// <c>[Period(..., RunOnStart = true)]</c> 在启动瞬间触发,也会在进入任何业务扫描 / 写入之前退出。
+/// 不再需要、也不得依赖 trigger 暂停作为隔离手段。</para>
+/// </summary>
+public static class S8JobSwitch
+{
+    /// <summary>环境级总开关配置键。默认 true(保持既有部署行为)。</summary>
+    public const string EnvironmentEnabledKey = "Scheduler:Enabled";
+
+    /// <summary>S8 业务级总开关配置键。默认 false。</summary>
+    public const string MasterEnabledKey = "S8:Scheduler:Enabled";
+
+    public const string EnvEnvironmentEnabled = "AIDOP_SCHEDULER_ENABLED";
+    public const string EnvMasterEnabled = "AIDOP_S8_SCHEDULER_ENABLED";
+
+    /// <summary>单 Job 配置键,形如 <c>S8:WatchScheduler:Enabled</c>。</summary>
+    public static string JobEnabledKey(S8BackgroundJob job) => $"S8:{SectionOf(job)}:Enabled";
+
+    /// <summary>单 Job env override 名,形如 <c>AIDOP_S8_WATCH_SCHEDULER_ENABLED</c>。</summary>
+    public static string JobEnvName(S8BackgroundJob job) => job switch
+    {
+        S8BackgroundJob.WatchScheduler => "AIDOP_S8_WATCH_SCHEDULER_ENABLED",
+        S8BackgroundJob.TimeoutAutoEscalation => "AIDOP_S8_TIMEOUT_AUTO_ESCALATION_ENABLED",
+        S8BackgroundJob.ActiveFlowStuckScan => "AIDOP_S8_ACTIVE_FLOW_STUCK_SCAN_ENABLED",
+        _ => throw new ArgumentOutOfRangeException(nameof(job), job, null),
+    };
+
+    private static string SectionOf(S8BackgroundJob job) => job switch
+    {
+        S8BackgroundJob.WatchScheduler => "WatchScheduler",
+        S8BackgroundJob.TimeoutAutoEscalation => "TimeoutAutoEscalation",
+        S8BackgroundJob.ActiveFlowStuckScan => "ActiveFlowStuckScan",
+        _ => throw new ArgumentOutOfRangeException(nameof(job), job, null),
+    };
+
+    /// <summary>求值三级开关。纯函数:不访问数据库、不解析租户、不产生副作用。</summary>
+    public static S8JobSwitchDecision Evaluate(IConfiguration configuration, S8BackgroundJob job)
+    {
+        ArgumentNullException.ThrowIfNull(configuration);
+
+        string? parseFailEnvName = null;
+
+        var environmentEnabled = ResolveBool(
+            configuration.GetValue(EnvironmentEnabledKey, true), EnvEnvironmentEnabled, ref parseFailEnvName);
+
+        var masterEnabled = ResolveBool(
+            configuration.GetValue(MasterEnabledKey, false), EnvMasterEnabled, ref parseFailEnvName);
+
+        var jobEnabled = ResolveBool(
+            configuration.GetValue(JobEnabledKey(job), false), JobEnvName(job), ref parseFailEnvName);
+
+        return new S8JobSwitchDecision(
+            EnvironmentEnabled: environmentEnabled,
+            MasterEnabled: masterEnabled,
+            JobEnabled: jobEnabled,
+            ParseFailEnvName: parseFailEnvName);
+    }
+
+    private static bool ResolveBool(bool fromJson, string envName, ref string? parseFailEnvName)
+    {
+        var raw = Environment.GetEnvironmentVariable(envName);
+        if (string.IsNullOrWhiteSpace(raw)) return fromJson;
+        if (bool.TryParse(raw, out var parsed)) return parsed;
+        parseFailEnvName ??= envName;
+        return fromJson;
+    }
+}
+
+/// <summary>三级开关求值结果。<see cref="Enabled"/> 为 false 时调用方必须立即 return。</summary>
+/// <param name="EnvironmentEnabled">环境级总开关 <c>Scheduler:Enabled</c> 的生效值。</param>
+/// <param name="MasterEnabled">S8 业务级总开关 <c>S8:Scheduler:Enabled</c> 的生效值。</param>
+/// <param name="JobEnabled">单 Job 开关 <c>S8:{Job}:Enabled</c> 的生效值。</param>
+/// <param name="ParseFailEnvName">首个 <c>bool.TryParse</c> 失败的 env 名;无失败为 null。</param>
+public sealed record S8JobSwitchDecision(
+    bool EnvironmentEnabled,
+    bool MasterEnabled,
+    bool JobEnabled,
+    string? ParseFailEnvName)
+{
+    /// <summary>三级与:任一为 false 即不执行业务体。</summary>
+    public bool Enabled => EnvironmentEnabled && MasterEnabled && JobEnabled;
+}

+ 8 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8ActiveFlowStuckScanJob.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Furion.Schedule;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -26,8 +27,6 @@ public class S8ActiveFlowStuckScanJob : IJob
     // 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)
     {
@@ -44,34 +43,21 @@ public class S8ActiveFlowStuckScanJob : IJob
         // 确保 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)
+        var gate = S8JobSwitch.Evaluate(_configuration, S8BackgroundJob.ActiveFlowStuckScan);
+        if (gate.ParseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
         {
             _logger.LogWarning(
                 "S8ActiveFlowStuckScanJob env override 解析失败:{EnvName},已沿用配置值",
-                parseFailEnvName);
+                gate.ParseFailEnvName);
         }
-        if (!schedulerEnabled || !s8SchedulerEnabled)
+        if (!gate.Enabled)
         {
             if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
             {
                 _logger.LogInformation(
-                    "S8ActiveFlowStuckScanJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}",
-                    schedulerEnabled, s8SchedulerEnabled);
+                    "S8ActiveFlowStuckScanJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler} {JobKey}={JobEnabled}",
+                    gate.EnvironmentEnabled, gate.MasterEnabled,
+                    S8JobSwitch.JobEnabledKey(S8BackgroundJob.ActiveFlowStuckScan), gate.JobEnabled);
             }
             return;
         }

+ 8 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8TimeoutAutoEscalationJob.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Furion.Schedule;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -29,8 +30,6 @@ public class S8TimeoutAutoEscalationJob : IJob
     // 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)
     {
@@ -45,34 +44,21 @@ public class S8TimeoutAutoEscalationJob : IJob
         // 任一为 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)
+        var gate = S8JobSwitch.Evaluate(_configuration, S8BackgroundJob.TimeoutAutoEscalation);
+        if (gate.ParseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
         {
             _logger.LogWarning(
                 "S8TimeoutAutoEscalationJob env override 解析失败:{EnvName},已沿用配置值",
-                parseFailEnvName);
+                gate.ParseFailEnvName);
         }
-        if (!schedulerEnabled || !s8SchedulerEnabled)
+        if (!gate.Enabled)
         {
             if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
             {
                 _logger.LogInformation(
-                    "S8TimeoutAutoEscalationJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}",
-                    schedulerEnabled, s8SchedulerEnabled);
+                    "S8TimeoutAutoEscalationJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler} {JobKey}={JobEnabled}",
+                    gate.EnvironmentEnabled, gate.MasterEnabled,
+                    S8JobSwitch.JobEnabledKey(S8BackgroundJob.TimeoutAutoEscalation), gate.JobEnabled);
             }
             return;
         }

+ 8 - 22
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S8WatchSchedulerJob.cs

@@ -1,4 +1,5 @@
 using Admin.NET.Plugin.AiDOP.Service.S8;
+using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
 using Furion.Schedule;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
@@ -53,8 +54,6 @@ public class S8WatchSchedulerJob : IJob
     // 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(
@@ -73,34 +72,21 @@ public class S8WatchSchedulerJob : IJob
         // 任一为 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)
+        var gate = S8JobSwitch.Evaluate(_configuration, S8BackgroundJob.WatchScheduler);
+        if (gate.ParseFailEnvName != null && Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0)
         {
             _logger.LogWarning(
                 "S8WatchSchedulerJob env override 解析失败:{EnvName},已沿用配置值",
-                parseFailEnvName);
+                gate.ParseFailEnvName);
         }
-        if (!schedulerEnabled || !s8SchedulerEnabled)
+        if (!gate.Enabled)
         {
             if (Interlocked.Exchange(ref _firstDisabledLogged, 1) == 0)
             {
                 _logger.LogInformation(
-                    "S8WatchSchedulerJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler}",
-                    schedulerEnabled, s8SchedulerEnabled);
+                    "S8WatchSchedulerJob 被配置禁用:Scheduler:Enabled={Scheduler} S8:Scheduler:Enabled={S8Scheduler} {JobKey}={JobEnabled}",
+                    gate.EnvironmentEnabled, gate.MasterEnabled,
+                    S8JobSwitch.JobEnabledKey(S8BackgroundJob.WatchScheduler), gate.JobEnabled);
             }
             return;
         }