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

feat(s8): add evaluator command timeout and max rows guard

YY968XX пре 21 часа
родитељ
комит
4239e0d46f

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

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

+ 69 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8EvaluatorGuard.cs

@@ -0,0 +1,69 @@
+using System.Globalization;
+using System.Threading;
+using Microsoft.Extensions.Logging;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
+
+/// <summary>
+/// S8-SQL-EVALUATOR-GUARD-P2-1:S8 三类 SQL evaluator 公共防护工具。
+/// 承载:
+///   1) AIDOP_S8_EVALUATOR_COMMAND_TIMEOUT_SECONDS / AIDOP_S8_EVALUATOR_MAX_ROWS env 解析(含默认值与下限保护,解析失败单次 warn);
+///   2) ApplyCommandTimeout:在 evaluator 独立 SqlSugarScope 上显式设置 Ado.CommandTimeOut;
+///   3) EnsureRowCountWithinLimit:检查返回行数,超限抛 S8RuleEvaluatorException(result_too_many_rows)。
+/// 不承载业务规则判断;不访问数据库;不修改 rule;不写业务数据明细;不读取 rule.Expression / dataSource.Endpoint / rule.ParamsJson。
+/// </summary>
+internal static class S8EvaluatorGuard
+{
+    public const int DefaultCommandTimeoutSeconds = 60;
+    public const int MinCommandTimeoutSeconds = 5;
+    public const int DefaultMaxRows = 1000;
+    public const int MinMaxRows = 1;
+
+    public const string EnvCommandTimeoutSeconds = "AIDOP_S8_EVALUATOR_COMMAND_TIMEOUT_SECONDS";
+    public const string EnvMaxRows = "AIDOP_S8_EVALUATOR_MAX_ROWS";
+
+    // 单进程内 AIDOP_S8_EVALUATOR_* 解析失败 warn 仅输出一次,避免日志刷屏。
+    // 与 S8WatchSchedulerJob._firstParseFailLogged 同形(Interlocked.Exchange 单次锚点)。
+    private static int _firstParseFailLogged;
+
+    public static int ResolveCommandTimeoutSeconds(ILogger? logger = null) =>
+        ResolveIntFromEnv(EnvCommandTimeoutSeconds, DefaultCommandTimeoutSeconds, MinCommandTimeoutSeconds, logger);
+
+    public static int ResolveMaxRows(ILogger? logger = null) =>
+        ResolveIntFromEnv(EnvMaxRows, DefaultMaxRows, MinMaxRows, logger);
+
+    public static void ApplyCommandTimeout(SqlSugarScope scope, int seconds)
+    {
+        scope.Ado.CommandTimeOut = seconds;
+    }
+
+    /// <summary>
+    /// 检查 evaluator SQL 返回行数;超限抛 S8RuleEvaluatorException(result_too_many_rows)。
+    /// 异常 message 仅含 ruleType / ruleCode / 实际行数 / 上限值,禁止携带 SQL 全文、连接串、ParamsJson、返回行内容。
+    /// </summary>
+    public static void EnsureRowCountWithinLimit(int actualRows, int maxRows, string ruleType, string ruleCode)
+    {
+        if (actualRows > maxRows)
+        {
+            throw new S8RuleEvaluatorException(
+                "result_too_many_rows",
+                $"{ruleType} 规则 {ruleCode} 返回 {actualRows} 行,超过安全上限 {maxRows},请缩小规则条件或拆分规则");
+        }
+    }
+
+    private static int ResolveIntFromEnv(string envName, int defaultValue, int minValue, ILogger? logger)
+    {
+        var raw = Environment.GetEnvironmentVariable(envName);
+        if (string.IsNullOrWhiteSpace(raw)) return defaultValue;
+        if (int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) && v >= minValue)
+            return v;
+        if (Interlocked.Exchange(ref _firstParseFailLogged, 1) == 0 && logger != null)
+        {
+            logger.LogWarning(
+                "s8_evaluator_guard_env_invalid env={Env} fallback={Fallback}",
+                envName, defaultValue);
+        }
+        return defaultValue;
+    }
+}

+ 20 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8OutOfRangeRuleEvaluator.cs

@@ -3,6 +3,7 @@ using System.Globalization;
 using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
@@ -25,10 +26,14 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
     private const string DefaultExceptionTypeCode = "EQUIP_FAULT";
 
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+    private readonly ILogger<S8OutOfRangeRuleEvaluator> _logger;
 
-    public S8OutOfRangeRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
+    public S8OutOfRangeRuleEvaluator(
+        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
+        ILogger<S8OutOfRangeRuleEvaluator> logger)
     {
         _dataSourceRep = dataSourceRep;
+        _logger = logger;
     }
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
@@ -66,10 +71,14 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
             || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
             throw new S8RuleEvaluatorException("data_source_unavailable", $"OUT_OF_RANGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
+        // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
+        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
+
         DataTable table;
         try
         {
-            using var db = CreateSqlScope(dataSource.Endpoint!);
+            using var db = CreateSqlScope(dataSource.Endpoint!, timeoutSeconds);
             table = await db.Ado.GetDataTableAsync(rule.Expression!);
         }
         catch (Exception ex)
@@ -77,6 +86,10 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
             throw new S8RuleEvaluatorException("query_failed", $"OUT_OF_RANGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
         }
 
+        // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。
+        // 必须置于 try-catch 之外,避免被 query_failed 误捕获再包装。
+        S8EvaluatorGuard.EnsureRowCountWithinLimit(table.Rows.Count, maxRows, RuleTypeCode, rule.RuleCode);
+
         var detectedAt = DateTime.Now;
         var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
             ? rule.WatchObjectType
@@ -154,10 +167,10 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
     }
 
-    private SqlSugarScope CreateSqlScope(string connectionString)
+    private SqlSugarScope CreateSqlScope(string connectionString, int commandTimeoutSeconds)
     {
         var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
-        return new SqlSugarScope(new ConnectionConfig
+        var scope = new SqlSugarScope(new ConnectionConfig
         {
             ConfigId = $"s8-oor-eval-{Guid.NewGuid():N}",
             DbType = dbType,
@@ -165,6 +178,9 @@ public class S8OutOfRangeRuleEvaluator : IS8RuleEvaluator, ITransient
             InitKeyType = InitKeyType.Attribute,
             IsAutoCloseConnection = true
         });
+        // 该独立 scope 不继承 SqlSugarSetup.SetDbAop 的全局 30s,必须显式设置。
+        S8EvaluatorGuard.ApplyCommandTimeout(scope, commandTimeoutSeconds);
+        return scope;
     }
 
     /// <summary>R3 OUT_OF_RANGE dedup_key:与 TIMEOUT/SHORTAGE 同形 T{t}:F{f}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。</summary>

+ 20 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8ShortageRuleEvaluator.cs

@@ -3,6 +3,7 @@ using System.Globalization;
 using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
@@ -27,10 +28,14 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
     private const string SqlDataSourceType = "SQL";
 
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+    private readonly ILogger<S8ShortageRuleEvaluator> _logger;
 
-    public S8ShortageRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
+    public S8ShortageRuleEvaluator(
+        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
+        ILogger<S8ShortageRuleEvaluator> logger)
     {
         _dataSourceRep = dataSourceRep;
+        _logger = logger;
     }
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
@@ -66,10 +71,14 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
             || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
             throw new S8RuleEvaluatorException("data_source_unavailable", $"SHORTAGE 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
+        // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
+        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
+
         DataTable table;
         try
         {
-            using var db = CreateSqlScope(dataSource.Endpoint!);
+            using var db = CreateSqlScope(dataSource.Endpoint!, timeoutSeconds);
             table = await db.Ado.GetDataTableAsync(rule.Expression!);
         }
         catch (Exception ex)
@@ -77,6 +86,10 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
             throw new S8RuleEvaluatorException("query_failed", $"SHORTAGE 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
         }
 
+        // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。
+        // 必须置于 try-catch 之外,避免被 query_failed 误捕获再包装。
+        S8EvaluatorGuard.EnsureRowCountWithinLimit(table.Rows.Count, maxRows, RuleTypeCode, rule.RuleCode);
+
         var detectedAt = DateTime.Now;
         var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
             ? rule.WatchObjectType
@@ -131,10 +144,10 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
     }
 
-    private SqlSugarScope CreateSqlScope(string connectionString)
+    private SqlSugarScope CreateSqlScope(string connectionString, int commandTimeoutSeconds)
     {
         var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
-        return new SqlSugarScope(new ConnectionConfig
+        var scope = new SqlSugarScope(new ConnectionConfig
         {
             ConfigId = $"s8-shortage-eval-{Guid.NewGuid():N}",
             DbType = dbType,
@@ -142,6 +155,9 @@ public class S8ShortageRuleEvaluator : IS8RuleEvaluator, ITransient
             InitKeyType = InitKeyType.Attribute,
             IsAutoCloseConnection = true
         });
+        // 该独立 scope 不继承 SqlSugarSetup.SetDbAop 的全局 30s,必须显式设置。
+        S8EvaluatorGuard.ApplyCommandTimeout(scope, commandTimeoutSeconds);
+        return scope;
     }
 
     /// <summary>构造 R3 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>

+ 20 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/S8TimeoutRuleEvaluator.cs

@@ -3,6 +3,7 @@ using System.Globalization;
 using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
+using Microsoft.Extensions.Logging;
 using SqlSugar;
 
 namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules;
@@ -22,10 +23,14 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
     private const string SqlDataSourceType = "SQL";
 
     private readonly SqlSugarRepository<AdoS8DataSource> _dataSourceRep;
+    private readonly ILogger<S8TimeoutRuleEvaluator> _logger;
 
-    public S8TimeoutRuleEvaluator(SqlSugarRepository<AdoS8DataSource> dataSourceRep)
+    public S8TimeoutRuleEvaluator(
+        SqlSugarRepository<AdoS8DataSource> dataSourceRep,
+        ILogger<S8TimeoutRuleEvaluator> logger)
     {
         _dataSourceRep = dataSourceRep;
+        _logger = logger;
     }
 
     public async Task<List<S8RuleHit>> EvaluateAsync(
@@ -62,10 +67,14 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             || !string.Equals(dataSource.Type?.Trim(), SqlDataSourceType, StringComparison.OrdinalIgnoreCase))
             throw new S8RuleEvaluatorException("data_source_unavailable", $"TIMEOUT 规则 {rule.RuleCode} 数据源不可用(id={rule.DataSourceId})");
 
+        // S8-SQL-EVALUATOR-GUARD-P2-1:每次评估解析 timeout / maxRows(env 优先,回退代码默认)。
+        var timeoutSeconds = S8EvaluatorGuard.ResolveCommandTimeoutSeconds(_logger);
+        var maxRows = S8EvaluatorGuard.ResolveMaxRows(_logger);
+
         DataTable table;
         try
         {
-            using var db = CreateSqlScope(dataSource.Endpoint!);
+            using var db = CreateSqlScope(dataSource.Endpoint!, timeoutSeconds);
             table = await db.Ado.GetDataTableAsync(rule.Expression!);
         }
         catch (Exception ex)
@@ -73,6 +82,10 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             throw new S8RuleEvaluatorException("query_failed", $"TIMEOUT 规则 {rule.RuleCode} SQL 执行失败:{ex.Message}", ex);
         }
 
+        // 超过安全上限 → result_too_many_rows(由既有 EVALUATE_FAILED 路径承接)。
+        // 必须置于 try-catch 之外,避免被 query_failed 误捕获再包装。
+        S8EvaluatorGuard.EnsureRowCountWithinLimit(table.Rows.Count, maxRows, RuleTypeCode, rule.RuleCode);
+
         var detectedAt = DateTime.Now;
         var threshold = detectedAt.AddMinutes(-parameters.GraceMinutes);
         var sourceObjectType = string.IsNullOrWhiteSpace(rule.SourceObjectType)
@@ -123,10 +136,10 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
         return hits;
     }
 
-    private SqlSugarScope CreateSqlScope(string connectionString)
+    private SqlSugarScope CreateSqlScope(string connectionString, int commandTimeoutSeconds)
     {
         var dbType = _dataSourceRep.Context.CurrentConnectionConfig.DbType;
-        return new SqlSugarScope(new ConnectionConfig
+        var scope = new SqlSugarScope(new ConnectionConfig
         {
             ConfigId = $"s8-timeout-eval-{Guid.NewGuid():N}",
             DbType = dbType,
@@ -134,6 +147,9 @@ public class S8TimeoutRuleEvaluator : IS8RuleEvaluator, ITransient
             InitKeyType = InitKeyType.Attribute,
             IsAutoCloseConnection = true
         });
+        // 该独立 scope 不继承 SqlSugarSetup.SetDbAop 的全局 30s,必须显式设置。
+        S8EvaluatorGuard.ApplyCommandTimeout(scope, commandTimeoutSeconds);
+        return scope;
     }
 
     /// <summary>构造 R2 dedup_key 稳定字符串:T{tenant}:F{factory}:R{ruleCode}:{sourceObjectType}:{sourceObjectId}。internal 暴露供测试。</summary>