Quellcode durchsuchen

fix: enforce configurable S8 refreshed detection rate limit | server 1.0.567

REFRESHED 明细限频窗口由硬编码 10 分钟改为可配置,默认 60 分钟。

- S8WatchSchedulerService: RefreshedRateLimitMinutes 常量拆为
  DefaultRefreshedRateLimitMinutes(60) + AIDOP_S8_REFRESHED_RATE_LIMIT_MINUTES
  env override,新增 ResolveRefreshedRateLimitMinutes();
  env 缺失/空白→默认,解析失败或 <1→默认 + 单次 warn(不输出 raw 值)。
  idiom 与既有 S8EvaluatorGuard.ResolveIntFromEnv 一致。
- 唯一调用点是 HasRecentRefreshedDetectionLogAsync 的 cutoff 计算;
  RefreshDetectionAsync / exception 状态更新 / CREATED / RECOVERED /
  RECOVERY_BLOCKED / 通知链路均未改动。
- docker-compose.yml: api 服务补 AIDOP_S8_LOG_CLEANUP_ENABLED=true 与
  AIDOP_S8_REFRESHED_RATE_LIMIT_MINUTES=60 部署环境变量。

口径提醒:限频判据为闭区间,业务 tick 默认 5 分钟,
实际写入周期 = 窗口 + 一个 tick(60 → 实测约 65 分钟)。

注意:aidopdev 当前存在多个 Scheduler 实例混跑,旧实例仍使用 10 分钟常量,
本改动需所有 backend 节点统一部署后方可验收。
YY968XX vor 11 Stunden
Ursprung
Commit
a6f3ac674f

+ 10 - 0
docker-compose.yml

@@ -29,6 +29,16 @@ services:
       TZ: Asia/Shanghai
       TZ: Asia/Shanghai
       # 见 Admin.NET.Core SqlSugarSetup:对已有库跳过 CodeFirst/种子,避免 “Multiple primary key” 等启动失败
       # 见 Admin.NET.Core SqlSugarSetup:对已有库跳过 CodeFirst/种子,避免 “Multiple primary key” 等启动失败
       DOCKER_SKIP_DBINIT: "1"
       DOCKER_SKIP_DBINIT: "1"
+      # S8-DETECTION-LOG-GROWTH-P0-A:启用 S8LogRetentionCleanupJob(默认 OFF)。
+      # 该 Job 只认进程环境变量(S8LogRetentionCleanupJob.EnvCleanupEnabled),不读 appsettings。
+      # 每日 00:00 分批物理 DELETE:detection_log 保留 30 天 / notification_log 90 天 /
+      # SysJobTriggerRecord 14 天;单批 1000 行、单表单次最多 50 批(5 万行/表/日封顶)。
+      # 保留天数、批大小、删除范围均未改动,仅打开开关。
+      AIDOP_S8_LOG_CLEANUP_ENABLED: "true"
+      # S8-DETECTION-LOG-GROWTH-P0-B:REFRESHED 明细限频窗口(分钟)。
+      # 不设置时代码默认已是 60;此处显式声明便于运维按需调参,无需发版。
+      # 注意实际写入周期 = 本值 + 一个业务 tick(默认 5 min),故 60 → 实测约 65 min。
+      AIDOP_S8_REFRESHED_RATE_LIMIT_MINUTES: "60"
     # 日志与上传持久化(按需调整宿主机路径)
     # 日志与上传持久化(按需调整宿主机路径)
     volumes:
     volumes:
       - aidop-api-logs:/app/logs
       - aidop-api-logs:/app/logs

+ 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.566</AssemblyVersion>
-    <FileVersion>1.0.566</FileVersion>
-    <Version>1.0.566</Version>
+    <AssemblyVersion>1.0.567</AssemblyVersion>
+    <FileVersion>1.0.567</FileVersion>
+    <Version>1.0.567</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 40 - 4
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/S8WatchSchedulerService.cs

@@ -57,9 +57,24 @@ public class S8WatchSchedulerService : ITransient
     private const string DetectResultRecoveryBlocked = "RECOVERY_BLOCKED";
     private const string DetectResultRecoveryBlocked = "RECOVERY_BLOCKED";
 
 
     // S8-DETECTION-LOG-WRITE-REDUCE-P1-1:REFRESHED 明细写入限频窗口。
     // S8-DETECTION-LOG-WRITE-REDUCE-P1-1:REFRESHED 明细写入限频窗口。
-    // 同 (tenant, factory, rule_code, dedup_key) 在 10 分钟内最多写一条 REFRESHED detection_log;
+    // 同 (tenant, factory, rule_code, dedup_key) 在该窗口内最多写一条 REFRESHED detection_log;
     // 业务侧 RefreshDetectionAsync / BackfillLegacyExceptionAsync 不受限频影响。
     // 业务侧 RefreshDetectionAsync / BackfillLegacyExceptionAsync 不受限频影响。
-    private const int RefreshedRateLimitMinutes = 10;
+    //
+    // S8-DETECTION-LOG-GROWTH-P0-B(2026-09-23):默认窗口由 10 → 60 分钟,并改为可配置。
+    // 口径提醒:限频判据是闭区间(DetectedAt >= cutoff),而业务 tick 为 5 分钟
+    // (S8:Scheduler:WatchTickIntervalMs 默认 300000),因此**实际写入周期 = 窗口 + 一个 tick**。
+    // 旧值 10 分钟实测跑成 15 分钟;新值 60 分钟实测应落在 65 分钟。
+    // 该窗口只影响 detection_log 明细密度,不影响 exception.last_detected_at /
+    // consecutive_hit_count / recovered_at / 通知链路——它们走 RefreshDetectionAsync,不经本限频。
+    private const int DefaultRefreshedRateLimitMinutes = 60;
+
+    // 优先级:环境变量 > 默认值(与 S8EvaluatorGuard.ResolveIntFromEnv 同一套 idiom)。
+    // 下限 1 分钟:仅防御 0 / 负数把限频整个关掉,不改变任何正常取值下的行为。
+    private const string EnvRefreshedRateLimitMinutes = "AIDOP_S8_REFRESHED_RATE_LIMIT_MINUTES";
+    private const int MinRefreshedRateLimitMinutes = 1;
+
+    // env 解析失败 warn 单进程内只输出一次,避免每 tick 刷日志。
+    private static int _firstRefreshedRateLimitParseFailLogged;
 
 
     private const string DefaultTriggerType = "VALUE_DEVIATION";
     private const string DefaultTriggerType = "VALUE_DEVIATION";
     private const string SqlDataSourceType = "SQL";
     private const string SqlDataSourceType = "SQL";
@@ -593,9 +608,30 @@ public class S8WatchSchedulerService : ITransient
         }
         }
     }
     }
 
 
+    /// <summary>
+    /// S8-DETECTION-LOG-GROWTH-P0-B:解析 REFRESHED 限频窗口(分钟)。
+    /// 优先级 env <c>AIDOP_S8_REFRESHED_RATE_LIMIT_MINUTES</c> &gt; 默认 60。
+    /// env 缺失/空白 → 默认值;解析失败或小于下限 → 默认值 + 单次 warn(不输出 raw 值)。
+    /// </summary>
+    private int ResolveRefreshedRateLimitMinutes()
+    {
+        var raw = Environment.GetEnvironmentVariable(EnvRefreshedRateLimitMinutes);
+        if (string.IsNullOrWhiteSpace(raw)) return DefaultRefreshedRateLimitMinutes;
+        if (int.TryParse(raw.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var v)
+            && v >= MinRefreshedRateLimitMinutes)
+            return v;
+        if (Interlocked.Exchange(ref _firstRefreshedRateLimitParseFailLogged, 1) == 0)
+        {
+            _logger.LogWarning(
+                "s8_refreshed_ratelimit_env_invalid env={Env} fallback={Fallback}",
+                EnvRefreshedRateLimitMinutes, DefaultRefreshedRateLimitMinutes);
+        }
+        return DefaultRefreshedRateLimitMinutes;
+    }
+
     /// <summary>
     /// <summary>
     /// S8-DETECTION-LOG-WRITE-REDUCE-P1-1:REFRESHED 明细限频判定。
     /// S8-DETECTION-LOG-WRITE-REDUCE-P1-1:REFRESHED 明细限频判定。
-    /// 查询 cutoff = detectedAt - RefreshedRateLimitMinutes 之后,
+    /// 查询 cutoff = detectedAt - 限频窗口(见 <see cref="ResolveRefreshedRateLimitMinutes"/>)之后,
     /// 是否已有同 (tenant_id, factory_id, rule_code, dedup_key, DetectResult=REFRESHED) 的 detection_log;
     /// 是否已有同 (tenant_id, factory_id, rule_code, dedup_key, DetectResult=REFRESHED) 的 detection_log;
     /// 命中既有索引 idx_s8_detection_log_dedup_time。查询失败仅 LogWarning 后返回 false,
     /// 命中既有索引 idx_s8_detection_log_dedup_time。查询失败仅 LogWarning 后返回 false,
     /// 宁可多写一条 REFRESHED 明细,也不阻断调度主链路;ruleCode / dedupKey 空白时同样返回 false(保留写入)。
     /// 宁可多写一条 REFRESHED 明细,也不阻断调度主链路;ruleCode / dedupKey 空白时同样返回 false(保留写入)。
@@ -607,7 +643,7 @@ public class S8WatchSchedulerService : ITransient
         if (string.IsNullOrWhiteSpace(ruleCode) || string.IsNullOrWhiteSpace(dedupKey))
         if (string.IsNullOrWhiteSpace(ruleCode) || string.IsNullOrWhiteSpace(dedupKey))
             return false;
             return false;
 
 
-        var cutoff = detectedAt.AddMinutes(-RefreshedRateLimitMinutes);
+        var cutoff = detectedAt.AddMinutes(-ResolveRefreshedRateLimitMinutes());
         try
         try
         {
         {
             return await _detectionLogRep.AsQueryable()
             return await _detectionLogRep.AsQueryable()