소스 검색

fix(覆盖规则): 分离触发与重写统计并按实际重写次数限额

dodo hold 4 시간 전
부모
커밋
aeb6eedb4c

+ 2 - 5
molilian.api/Controllers/admin/OverrideRulesController.cs

@@ -65,13 +65,10 @@ namespace molilian.api.Controllers
                .Order(orderBy)
                .PageList<OverrideRuleDTO>(getTotal);
 
+            var now = DateTime.Now;
             foreach (var item in result.List)
             {
-                string hourlyKey = $":override_rule_calls:{item.id}:{DateTime.Now:yyyyMMddHH}";
-                string dailyKey = $":override_rule_calls:{item.id}:{DateTime.Now:yyyyMMdd}";
-
-                item.current_daily_calls = await RedisKit.GetAsync<int>(hourlyKey);
-                item.current_hourly_calls = await RedisKit.GetAsync<int>(hourlyKey);
+                await OverrideRuleCore.LoadStatisticsAsync(item, now);
             }
             return new APIResult(new { data = result });
         }

+ 84 - 0
molilian.core/Core/OverrideRuleCore.Counters.cs

@@ -0,0 +1,84 @@
+using dodohold.core;
+using System.Globalization;
+
+namespace molilian.core
+{
+    public partial class OverrideRuleCore
+    {
+        // KEYS: 小时触发、日触发、小时重写、日重写。沿用现有 key,保留历史数据。
+        // 同一脚本内检查两项额度并更新计数,避免并发超额或只扣掉其中一项额度。
+        private const string RecordRewriteScript = @"
+local hourlyRewrites = tonumber(redis.call('GET', KEYS[3]) or '0')
+local dailyRewrites = tonumber(redis.call('GET', KEYS[4]) or '0')
+local hourlyLimit = tonumber(ARGV[1])
+local dailyLimit = tonumber(ARGV[2])
+
+redis.call('INCR', KEYS[1])
+redis.call('EXPIRE', KEYS[1], 7200)
+redis.call('INCR', KEYS[2])
+redis.call('EXPIRE', KEYS[2], 259200)
+
+if (hourlyLimit > 0 and hourlyRewrites >= hourlyLimit)
+    or (dailyLimit > 0 and dailyRewrites >= dailyLimit) then
+    return 0
+end
+
+redis.call('INCR', KEYS[3])
+redis.call('EXPIRE', KEYS[3], 15552000)
+redis.call('INCR', KEYS[4])
+redis.call('EXPIRE', KEYS[4], 15552000)
+return 1";
+
+        private static string[] GetCounterKeys(OverrideRuleDTO rule, DateTime now) =>
+        [
+            $":override_rule_calls:{rule.id}:{now:yyyyMMddHH}",
+            $":override_rule_calls:{rule.id}:{now:yyyyMMdd}",
+            $":keyword_stats:{rule.platform}:{rule.id}:{now:yyyyMMddHH}",
+            $":keyword_stats:{rule.platform}:{rule.id}:{now:yyyyMMdd}"
+        ];
+
+        private static async Task<bool> TryRecordRewriteAsync(OverrideRuleDTO rule, DateTime now)
+        {
+            try
+            {
+                // RedisUtility 与 RedisKit 均使用 RedisConfig,复用已初始化的连接池。
+                // CSRedis 高层 Eval 仅支持一个 key;这里通过原生接口显式传入全部四个 key。
+                var pool = RedisHelper.Instance.Nodes.Values.Single();
+                var connection = await pool.GetAsync();
+                Exception? error = null;
+                try
+                {
+                    var keys = GetCounterKeys(rule, now).Select(key => pool.Prefix + key).ToArray();
+                    var result = await connection.Value.EvalAsync(RecordRewriteScript, keys,
+                        rule.hourly_calls_limit, rule.daily_calls_limit);
+                    return Convert.ToInt64(result, CultureInfo.InvariantCulture) == 1;
+                }
+                catch (Exception ex)
+                {
+                    error = ex;
+                    throw;
+                }
+                finally
+                {
+                    pool.Return(connection, error);
+                }
+            }
+            catch (Exception ex)
+            {
+                _ = new LoggerLibrary("OverrideRule", "Redis")
+                    .Info($"Rewrite limit check failed: {ex.Message}", ex.StackTrace).SaveAsync();
+                // 无法确认额度时跳过受限规则;无限额规则保留统计故障时继续工作的行为。
+                return rule.hourly_calls_limit <= 0 && rule.daily_calls_limit <= 0;
+            }
+        }
+
+        public static async Task LoadStatisticsAsync(OverrideRuleDTO rule, DateTime now)
+        {
+            var counts = await RedisHelper.MGetAsync<int>(GetCounterKeys(rule, now));
+            rule.current_hourly_calls = counts[0];
+            rule.current_daily_calls = counts[1];
+            rule.current_hourly_rewrites = counts[2];
+            rule.current_daily_rewrites = counts[3];
+        }
+    }
+}

+ 11 - 56
molilian.core/Core/OverrideRuleCore.cs

@@ -134,47 +134,6 @@ namespace molilian.core
                     if (is_ignore) continue;
                 }
 
-                // 每小时调用次数统计(无论是否设置限制都要记录)
-                string hourlyKey = $":override_rule_calls:{item.id}:{DateTime.Now:yyyyMMddHH}";
-                try
-                {
-                    long currentHourlyCalls = await RedisKit.IncrByAsync(hourlyKey);
-                    // 如果是第一次调用,设置过期时间为2小时
-                    if (currentHourlyCalls < 10)
-                    {
-                        await RedisKit.ExpireAsync(hourlyKey, 7200); // 2小时
-                    }
-
-                    // 如果设置了每小时限制且超过限制,跳过该规则
-                    if (item.hourly_calls_limit > 0 && currentHourlyCalls > item.hourly_calls_limit) continue;
-                }
-                catch (Exception ex)
-                {
-                    // Redis错误时记录日志但不阻塞业务
-                    _ = new LoggerLibrary("OverrideRule", "Redis").Info($"Hourly limit check failed: {ex.Message}", ex.StackTrace).SaveAsync();
-                }
-
-                // 每日调用次数统计(无论是否设置限制都要记录)
-                string dailyKey = $":override_rule_calls:{item.id}:{DateTime.Now:yyyyMMdd}";
-                try
-                {
-                    long currentDailyCalls = await RedisKit.IncrByAsync(dailyKey);
-                    if (currentDailyCalls < 10)
-                    {
-                        await RedisKit.ExpireAsync(dailyKey, 259200); // 3天
-                    }
-                    // 如果设置了每日限制且超过限制,跳过该规则
-                    if (item.daily_calls_limit > 0 && currentDailyCalls >= item.daily_calls_limit) continue;
-                }
-                catch (Exception ex)
-                {
-                    // Redis错误时记录日志但不阻塞业务
-                    _ = new LoggerLibrary("OverrideRule", "Redis").Info($"Daily limit check failed: {ex.Message}", ex.StackTrace).SaveAsync();
-                }
-
-                // 关键词统计 - 长期缓存(6个月)用于历史数据分析
-                await SaveKeywordStatisticsAsync(item.id, item.platform, original_text);
-
                 string output_text = item.output_text;
                 if (output_text.Contains("{url:query_text}")) output_text = output_text.Replace("{url:query_text}", HttpUtility.UrlEncode(original_text));
                 if (output_text.Contains("{url2:query_text}")) output_text = output_text.Replace("{url2:query_text}", HttpUtility.UrlEncode(HttpUtility.UrlEncode(original_text)));
@@ -184,6 +143,12 @@ namespace molilian.core
                 // 返回副本,避免占位符替换结果写回缓存中的规则
                 var matched = item.Clone();
                 matched.output_text = output_text;
+
+                var now = DateTime.Now;
+                if (!await TryRecordRewriteAsync(item, now)) continue;
+
+                // 日、小时重写次数已经与限额一起原子更新,这里只记录其他历史统计。
+                await SaveKeywordStatisticsAsync(item.id, item.platform, original_text, now);
                 return matched;
             }
             return null;
@@ -193,7 +158,7 @@ namespace molilian.core
         /// 保存关键词统计数据到Redis,用于长期数据分析
         /// 缓存周期:6个月
         /// </summary>
-        private static async Task SaveKeywordStatisticsAsync(int ruleId, string platform, string keyword)
+        private static async Task SaveKeywordStatisticsAsync(int ruleId, string platform, string keyword, DateTime now)
         {
             try
             {
@@ -201,32 +166,22 @@ namespace molilian.core
                 const int sixMonthsInSeconds = 180 * 86400;
 
                 // 关键词总请求次数统计 - 按月份
-                string monthlyKey = $":keyword_stats:{platform}:{ruleId}:{DateTime.Now:yyyyMM}";
+                string monthlyKey = $":keyword_stats:{platform}:{ruleId}:{now:yyyyMM}";
                 await RedisKit.IncrByAsync(monthlyKey);
                 await RedisKit.ExpireAsync(monthlyKey, sixMonthsInSeconds);
 
-                // 关键词总请求次数统计 - 按日
-                string dailyKey = $":keyword_stats:{platform}:{ruleId}:{DateTime.Now:yyyyMMdd}";
-                await RedisKit.IncrByAsync(dailyKey);
-                await RedisKit.ExpireAsync(dailyKey, sixMonthsInSeconds);
-
-                // 关键词总请求次数统计 - 按小时
-                string hourlyKey = $":keyword_stats:{platform}:{ruleId}:{DateTime.Now:yyyyMMddHH}";
-                await RedisKit.IncrByAsync(hourlyKey);
-                await RedisKit.ExpireAsync(hourlyKey, sixMonthsInSeconds);
-
                 // 平台维度统计
-                string platformMonthlyKey = $":keyword_stats:platform:{platform}:{DateTime.Now:yyyyMM}";
+                string platformMonthlyKey = $":keyword_stats:platform:{platform}:{now:yyyyMM}";
                 await RedisKit.IncrByAsync(platformMonthlyKey);
                 await RedisKit.ExpireAsync(platformMonthlyKey, sixMonthsInSeconds);
 
                 // 全局统计
-                string globalMonthlyKey = $":keyword_stats:global:{DateTime.Now:yyyyMM}";
+                string globalMonthlyKey = $":keyword_stats:global:{now:yyyyMM}";
                 await RedisKit.IncrByAsync(globalMonthlyKey);
                 await RedisKit.ExpireAsync(globalMonthlyKey, sixMonthsInSeconds);
 
                 // 记录关键词集合(用于后续查询有哪些关键词)
-                string keywordSetKey = $":keyword_stats:set:{platform}:{DateTime.Now:yyyyMM}";
+                string keywordSetKey = $":keyword_stats:set:{platform}:{now:yyyyMM}";
                 await RedisKit.SAddAsync(keywordSetKey, keyword);
                 await RedisKit.ExpireAsync(keywordSetKey, sixMonthsInSeconds);
             }

+ 9 - 0
molilian.core/DTO/OverrideRuleDTO.cs

@@ -42,11 +42,20 @@ namespace molilian.core
         public int hourly_calls_limit { get; set; } = 0;
 
 
+        /// <summary>当日触发次数,包含超限后未重写的请求。</summary>
         [IgnoreInsert, IgnoreUpdate]
         public int current_daily_calls { get; set; } = 0;
+        /// <summary>当前小时触发次数,包含超限后未重写的请求。</summary>
         [IgnoreInsert, IgnoreUpdate]
         public int current_hourly_calls { get; set; } = 0;
 
+        /// <summary>当日实际重写次数,与覆写响应中的 success 值无关。</summary>
+        [IgnoreInsert, IgnoreUpdate]
+        public int current_daily_rewrites { get; set; } = 0;
+        /// <summary>当前小时实际重写次数,与覆写响应中的 success 值无关。</summary>
+        [IgnoreInsert, IgnoreUpdate]
+        public int current_hourly_rewrites { get; set; } = 0;
+
         /// <summary>
         /// 是否验证参数:0不限制, 1入参special_text=1 时才匹配
         /// </summary>