dodo hold 1 ヶ月 前
コミット
09e2210923

+ 34 - 26
molilian.api/Controllers/public/TkEndpointController.cs

@@ -30,21 +30,29 @@ namespace molilian.api.Controllers
             core = new TaokeOpenCore();
         }
 
-        [HttpPost]
-        public async Task<ActionResult> ChangeSuspend([FromForm] JsonElement body)
-        {
-            int accountId = body.Read<int>("accountId");
-            string endpoint = body.Read<string>("accountId");
-            bool release = body.Read<bool>("release");
-
-            if (release)
-            {
-                TkEndpointManager.ReleaseSuspend(accountId, endpoint);
-            }
-            else
-            {
-                await TkEndpointManager.SuspendAsync(accountId, endpoint, TkEndpointManager.SuspendReason.Active);
-            }
+        [HttpPost]
+        public async Task<ActionResult> ChangeSuspend([FromForm] JsonElement body)
+        {
+            int accountId = body.Read<int>("accountId");
+            string endpoint = body.Read<string>("endpoint");
+            bool release = body.Read<bool>("release");
+            int durationSeconds = body.Read<int>("durationSeconds");
+
+            if (release)
+            {
+                TkEndpointManager.ReleaseSuspend(accountId, endpoint, notifyOtherNodes: false);
+            }
+            else
+            {
+                if (durationSeconds > 0)
+                {
+                    await TkEndpointManager.SuspendForDurationAsync(accountId, endpoint, TimeSpan.FromSeconds(durationSeconds), TkEndpointManager.SuspendReason.Active, notifyOtherNodes: false);
+                }
+                else
+                {
+                    await TkEndpointManager.SuspendAsync(accountId, endpoint, TkEndpointManager.SuspendReason.Active, notifyOtherNodes: false);
+                }
+            }
 
             return new APIResult(new
             {
@@ -54,19 +62,19 @@ namespace molilian.api.Controllers
         }
 
 
-        [HttpPost]
-        public ActionResult GetStatus([FromForm] JsonElement body)
-        {
-            var ids = body.PathReadArray<int>("accountId[]");
-            var result = TkEndpointManager.GetStatus(ids);
-
-            return new APIResult(new
-            {
-                success = true,
-                data = result
+        [HttpPost]
+        public async Task<ActionResult> GetStatus([FromForm] JsonElement body)
+        {
+            var ids = body.PathReadArray<int>("accountId[]");
+            var result = await TkEndpointManager.GetStatus(ids);
+
+            return new APIResult(new
+            {
+                success = true,
+                data = result
             });
         }
 
 
     }
-}
+}

ファイルの差分が大きいため隠しています
+ 0 - 0
molilian.api/Properties/PublishProfiles/latest.pubxml.user


+ 3 - 3
molilian.api/Properties/launchSettings.json

@@ -4,7 +4,7 @@
       "commandName": "Project",
       "launchBrowser": true,
       "launchUrl": "swagger",
-      "environmentVariables2": {
+      "environmentVariables22": {
         "ASPNETCORE_ENVIRONMENT": "Development",
         "EndPoint": "coupon",
         "NtfyServer": "https://ntfy.yunhui800.com/vozQub8a78ABLqqY",
@@ -16,7 +16,7 @@
         "CenterDB": "",
         "CenterRedis": ""
       },
-      "environmentVariables": {
+      "environmentVariables2": {
         "ASPNETCORE_ENVIRONMENT": "Development",
         "EndPoint": "sh1",
         "NtfyServer": "https://ntfy.yunhui800.com/similar",
@@ -26,7 +26,7 @@
         "RedisConfig": "47.102.204.251:6379,password=pKBiS4ka2IpXayIdcx00,defaultDDB_REDISatabase=0,idleTimeout=20000,preheat=3,tryit=2,ssl=false,prefix=similar",
         "DB_REDIS": "47.102.204.251:6379,password=pKBiS4ka2IpXayIdcx00,defaultDatabase=0,idleTimeout=20000,preheat=3,tryit=2,ssl=false,prefix=similar"
       },
-      "environmentVariables22": {
+      "environmentVariables": {
         "ASPNETCORE_ENVIRONMENT": "Development",
         "EndPoint": "bj",
         "NtfyServer": "https://ntfy.yunhui800.com/5Sq9BytXXM5WDY3G",

+ 30 - 18
molilian.api/docker-compose.yml

@@ -1,29 +1,41 @@
 #docker login -u dodohold -p u9BkH5pxkACn43et docker.yunhui800.com
 version: "3"
-services:
-    molilian:
-        container_name: molilian
-        image: docker.yunhui800.com/molilian:latest
-        restart: always
-        ports:
-            - 5002:8080
-        volumes:
-            - ./logs:/app/log
-            - /etc/localtime:/etc/localtime:ro
+services:
+    molilian:
+        container_name: molilian
+        image: docker.yunhui800.com/molilian:latest
+        restart: always
+        mem_limit: 8g
+        mem_reservation: 6g
+        ports:
+            - 5002:8080
+        volumes:
+            - ./logs:/app/log
+            - /etc/localtime:/etc/localtime:ro
             - /var/run/docker.sock:/var/run/docker.sock # 挂载宿主机的 Docker UNIX 套接字
         devices:
             - /dev/rtc:/dev/rtc:ro
         environment:
-            # 通知服务器
-            - NtfyServer=
-            - ANPush=
-            
-            # 数据库
-            - DBConfig=Server=host.docker.internal; Port=3306; Database=taoke; Uid=taoke; Pwd=67ktWBmw5G4yMs4J;SslMode=None;CharSet=utf8mb4;ConnectionTimeout=60;
-            - DBType=MySQL
+            # 通知服务器
+            - NtfyServer=
+            - ANPush=
+
+            # 系统监控
+            - SystemMonitorMemoryAlertMB=6144
+            - SystemMonitorMemoryRecoveryMB=5632
+            - SystemMonitorMemoryAlertConsecutiveHits=3
+            - SystemMonitorMemoryAlertCooldownMinutes=30
+
+            # tbpush/icon 临时限流
+            - TbPushIconThrottleWindowSeconds=5
+            - TbPushIconThrottleMaxRequests=100
+            
+            # 数据库
+            - DBConfig=Server=host.docker.internal; Port=3306; Database=taoke; Uid=taoke; Pwd=67ktWBmw5G4yMs4J;SslMode=None;CharSet=utf8mb4;ConnectionTimeout=60;
+            - DBType=MySQL
 
             # redis 内存数据库
             - RedisConfig=host.docker.internal:6379,password=ef4f629b9,defaultDatabase=0,idleTimeout=20000,preheat=3,tryit=2,ssl=false,prefix=webhook
             
             - CenterDB=
-            - CenterRedis=
+            - CenterRedis=

+ 21 - 18
molilian.core/Core/EndPointCore.cs

@@ -207,24 +207,27 @@ Server=rm-2ze74506m3gfsqe7mco.rwlb.rds.aliyuncs.com; Port=3306; Database=coupon;
         }
 
 
-        public static async Task NotifyChangeSuspend(int accountId, string endpoint, bool release = false, CancellationToken cancellationToken = default)
-        {
-            await ProcessEndPointNodesAsync(async node =>
-            {
-                string post = string.Empty;
-                try
-                {
-                    string url = $"{node.api_server}api/TkEndpoint/ChangeSuspend";
-                    post = new
-                    {
-                        accountId,
-                        endpoint,
-                        release
-                    }.Convert2Json();
-                    await new WebClientUtility().Post(post).RequestAsync(url, "POST", cancellationToken);
-                }
-                catch (Exception ex)
-                {
+        public static async Task NotifyChangeSuspend(int accountId, string endpoint, bool release = false, int? durationSeconds = null, CancellationToken cancellationToken = default)
+        {
+            await ProcessEndPointNodesAsync(async node =>
+            {
+                string post = string.Empty;
+                try
+                {
+                    if (string.Equals(node.name, CurrentEndPoint, StringComparison.OrdinalIgnoreCase)) return;
+
+                    string url = $"{node.api_server}api/TkEndpoint/ChangeSuspend";
+                    post = new
+                    {
+                        accountId,
+                        endpoint,
+                        release,
+                        durationSeconds
+                    }.Convert2Json();
+                    await new WebClientUtility().Post(post).RequestAsync(url, "POST", cancellationToken);
+                }
+                catch (Exception ex)
+                {
                     _ = new LoggerLibrary("TkEndpoint", "ChangeSuspend_error")
                     .Info(node.Convert2Json(), post)
                     .Info(ex.Message, ex.StackTrace)

+ 46 - 16
molilian.core/Core/log/deeplink.cs

@@ -9,7 +9,12 @@ namespace molilian.core
     public partial class TkLogCore
     {
 
-        static string queue_deeplink_parse_key = "queue:parse_logs:deeplink";
+        static string queue_deeplink_parse_key = "queue:parse_logs:deeplink";
+        static readonly System.Text.RegularExpressions.Regex[] deeplink_log_content_exclude_patterns =
+        [
+            // 例:😆 AaSWvWlmhEf 😆 CA4422
+            new(@"^[^A-Za-z0-9]*[A-Za-z0-9]{11}[^A-Za-z0-9]+[A-Za-z]{2}\d{3,4}\s*$", System.Text.RegularExpressions.RegexOptions.Compiled),
+        ];
 
         public static async Task<int> InsertDeeplinkLogAsync(int limit, YunhuiKit.RedisClient redis)
         {
@@ -198,25 +203,50 @@ namespace molilian.core
             //public DateTime create_time { get; set; } = DateTime.Now;
             //public string end_point { get; set; } = string.Empty;
 
-            string content = data.content;
-            if (content.Length > 20000) content = $"{content[..20000]}...";
-
-            return new DBContext.Table(connection, tablename)
-                .Add("end_point", data.end_point)
-                .Add("channel_name", data.channel_name)
-                .Add("success", data.success)
+            string content = data.content;
+            if (content.Length > 20000) content = $"{content[..20000]}...";
+            if (tablename.StartsWith("deeplink_parse_logs_fail") && DeeplinkLogContentExcluded(content))
+            {
+                return 0;
+            }
+
+            return new DBContext.Table(connection, tablename)
+                .Add("end_point", data.end_point)
+                .Add("channel_name", data.channel_name)
+                .Add("success", data.success)
                 .Add("message", data.message)
                 .Add("reason", data.reason)
                 .Add("content", content)
                 .Add("ip", data.ip)
                 .Add("oaid", data.oaid)
-                .Add("deeplink_url", data.deeplink_url)
-                .Add("itemName", data.itemName)
-                .Add("elapsedTime", data.elapsedTime)
-                .Add("create_time", data.create_time)
-                .Create(DBContext.InsertType.NORMAL, transaction);
-        }
-
-    }
+                .Add("deeplink_url", data.deeplink_url)
+                .Add("itemName", data.itemName)
+                .Add("elapsedTime", data.elapsedTime)
+                .Add("create_time", data.create_time)
+                .Create(DBContext.InsertType.NORMAL, transaction);
+        }
+
+        private static bool DeeplinkLogContentExcluded(string content)
+        {
+            if (string.IsNullOrWhiteSpace(content)) return false;
+
+            foreach (var pattern in deeplink_log_content_exclude_patterns)
+            {
+                try
+                {
+                    if (pattern.IsMatch(content))
+                    {
+                        return true;
+                    }
+                }
+                catch
+                {
+                }
+            }
+
+            return false;
+        }
+
+    }
 
 }

+ 60 - 36
molilian.core/Core/taoke/TkEndpointCore.cs

@@ -14,7 +14,7 @@ public partial class TkEndpointCore
     private static IEnumerable<TkEndpointConfigDTO> _cached;
 
     // 添加到 TkEndpointCore 类中
-    private static readonly ConcurrentDictionary<(int, bool?), List<TkEndpointConfigDTO>> _accountEndpointCache = new();
+    private static readonly ConcurrentDictionary<(int, bool?, string), List<TkEndpointConfigDTO>> _accountEndpointCache = new();
 
 
     private static async Task<IEnumerable<TkEndpointConfigDTO>> ListAsync(bool force = false)
@@ -74,15 +74,26 @@ public partial class TkEndpointCore
     }
 
 
-    public static async Task<List<TkEndpointConfigDTO>> GetEndpointsByAccountAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoints = null)
-    {
-        var cacheKey = (accountId, isTaobaoUrl);
-
-        if (_accountEndpointCache.TryGetValue(cacheKey, out var cached))
-            return cached.Select(x => x.DeepCopy()).ToList();
-
-        // 2. 获取所有端点(包含全局和专属)
-        var allEndpoints = (await ListAsync())?.Where(e => e.status).ToList() ?? [];
+    public static Task<List<TkEndpointConfigDTO>> GetEndpointsByAccountAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoints = null)
+    {
+        return GetEndpointsByAccountInternalAsync(accountId, isTaobaoUrl, parseEndpoints, cloneResult: true);
+    }
+
+    public static Task<List<TkEndpointConfigDTO>> GetEndpointsByAccountReadonlyAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoints = null)
+    {
+        return GetEndpointsByAccountInternalAsync(accountId, isTaobaoUrl, parseEndpoints, cloneResult: false);
+    }
+
+    private static async Task<List<TkEndpointConfigDTO>> GetEndpointsByAccountInternalAsync(int accountId, bool? isTaobaoUrl, string parseEndpoints, bool cloneResult)
+    {
+        string normalizedParseEndpoints = NormalizeParseEndpoints(parseEndpoints);
+        var cacheKey = (accountId, isTaobaoUrl, normalizedParseEndpoints);
+
+        if (_accountEndpointCache.TryGetValue(cacheKey, out var cached))
+            return cloneResult ? cached.Select(x => x.DeepCopy()).ToList() : cached;
+
+        // 2. 获取所有端点(包含全局和专属)
+        var allEndpoints = (await ListAsync())?.Where(e => e.status).ToList() ?? [];
 
         // 3. 合并逻辑:专属配置覆盖全局配置(关键!)
         var mergedEndpoints = allEndpoints
@@ -114,39 +125,52 @@ public partial class TkEndpointCore
         }
 
         // 5. 排除指定的端点
-        if (!string.IsNullOrEmpty(parseEndpoints))
-        {
-            var parseEndpointIds = parseEndpoints.Split(',')
-                .Select(idStr => int.TryParse(idStr.Trim(), out var id) ? id : (int?)null)
-                .Where(id => id.HasValue)
-                .Select(id => id.Value)
-                .ToHashSet();
-
-            mergedEndpoints = mergedEndpoints.Where(e => parseEndpointIds.Contains(e.ep_id)).ToList();
-        }
-
-        // 6. 存入缓存
-        _accountEndpointCache.TryAdd(cacheKey, mergedEndpoints.Select(x => x.DeepCopy()).ToList());
-        return mergedEndpoints;
-    }
+        if (!string.IsNullOrEmpty(normalizedParseEndpoints))
+        {
+            var parseEndpointIds = normalizedParseEndpoints.Split(',')
+                .Select(idStr => int.TryParse(idStr.Trim(), out var id) ? id : (int?)null)
+                .Where(id => id.HasValue)
+                .Select(id => id.Value)
+                .ToHashSet();
+
+            mergedEndpoints = mergedEndpoints.Where(e => parseEndpointIds.Contains(e.ep_id)).ToList();
+        }
+
+        // 6. 存入缓存
+        var cachedValue = mergedEndpoints.Select(x => x.DeepCopy()).ToList();
+        _accountEndpointCache.TryAdd(cacheKey, cachedValue);
+        return cloneResult ? cachedValue.Select(x => x.DeepCopy()).ToList() : cachedValue;
+    }
 
 
 
     // 添加缓存清理方法
-    public static void ClearEndpointCache(int accountId)
-    {
-        _accountEndpointCache.TryRemove((accountId, true), out _);
-        _accountEndpointCache.TryRemove((accountId, false), out _);
-        _accountEndpointCache.TryRemove((accountId, null), out _);
-    }
+    public static void ClearEndpointCache(int accountId)
+    {
+        foreach (var key in _accountEndpointCache.Keys.Where(key => key.Item1 == accountId).ToList())
+        {
+            _accountEndpointCache.TryRemove(key, out _);
+        }
+    }
 
 
 
     public static void Refresh()
     {
         _ = AllListAsync(true);
-        _ = ListAsync(true);
-        _accountEndpointCache.Clear();
-    }
-
-}
+        _ = ListAsync(true);
+        _accountEndpointCache.Clear();
+    }
+
+    private static string NormalizeParseEndpoints(string? parseEndpoints)
+    {
+        if (string.IsNullOrWhiteSpace(parseEndpoints)) return string.Empty;
+
+        return string.Join(",",
+            parseEndpoints.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+                .Where(item => !string.IsNullOrWhiteSpace(item))
+                .Distinct(StringComparer.Ordinal)
+                .OrderBy(item => item, StringComparer.Ordinal));
+    }
+
+}

+ 249 - 113
molilian.core/Core/taoke/TkEndpointManager.cs

@@ -5,20 +5,28 @@ using System;
 using System.Collections.Concurrent;
 using System.Linq;
 
-public partial class TkEndpointManager
-{
-    // 只保留轮询索引
-    private static readonly ConcurrentDictionary<int, AccountEndpointState> accountStates = new();
-
-    // 记录每个账号最后一次挂起操作的时间
-    private static readonly ConcurrentDictionary<int, DateTime> lastSuspendTimes = new();
-
-
-    public static void Refresh()
-    {
-        accountStates.Clear();
-        lastSuspendTimes.Clear();
-    }
+public partial class TkEndpointManager
+{
+    // 只保留轮询索引
+    private static readonly ConcurrentDictionary<int, AccountEndpointState> accountStates = new();
+
+    // 记录每个账号最后一次挂起操作的时间
+    private static readonly ConcurrentDictionary<int, DateTime> lastSuspendTimes = new();
+
+    // 短时缓存挂起状态,削峰同一波请求对 Redis Exists 的放大访问
+    private static readonly ConcurrentDictionary<string, SuspendCacheEntry> suspendStateCache = new();
+    private static readonly ConcurrentDictionary<string, byte> suspendStateRefreshInFlight = new();
+    private static readonly TimeSpan SuspendStateCacheDuration = TimeSpan.FromSeconds(10);
+    private static readonly TimeSpan SuspendStateFailureBackoffDuration = TimeSpan.FromSeconds(3);
+
+
+    public static void Refresh()
+    {
+        accountStates.Clear();
+        lastSuspendTimes.Clear();
+        suspendStateCache.Clear();
+        suspendStateRefreshInFlight.Clear();
+    }
 
 
     // 从数据库获取所有可用的API端点
@@ -45,11 +53,20 @@ public partial class TkEndpointManager
         }
 
         return dict;
-    }
-    public static bool IsEndpointSuspended(int accountId, string endpoint)
-    {
-        return RedisHelper.Exists($"tk_suspend:{accountId}:{endpoint}");
-    }
+    }
+    public static bool IsEndpointSuspended(int accountId, string endpoint)
+    {
+        if (string.IsNullOrWhiteSpace(endpoint)) return false;
+
+        string key = BuildSuspendKey(accountId, endpoint);
+        if (TryGetCachedSuspendState(key, out var isSuspended))
+            return isSuspended;
+
+        // 热路径不再同步访问 Redis。缓存未命中时先降级放行,再异步探测 Redis 状态。
+        _ = RefreshSuspendStateAsync(key);
+        CacheSuspendState(key, false, SuspendStateFailureBackoffDuration);
+        return false;
+    }
 
     /// <summary>
     /// 检查指定账号是否至少拥有一个可用的endpoint
@@ -61,12 +78,13 @@ public partial class TkEndpointManager
         var availableApis = await GetAllAvailableApisAsync(accountId);
         if (availableApis.Count == 0) return false;
 
-        if (!accountStates.TryGetValue(accountId, out var state))
-        {
-            return true; // 如果账号没有状态记录,所有节点都是正常的
-        }
-
-        return availableApis.Any(api => !IsEndpointSuspended(accountId, api.Value));
+        if (!accountStates.TryGetValue(accountId, out var state))
+        {
+            return true; // 如果账号没有状态记录,所有节点都是正常的
+        }
+
+        var suspendStates = GetSuspendStates(accountId, availableApis.Values);
+        return availableApis.Any(api => !suspendStates.GetValueOrDefault(api.Value));
     }
 
     /// <summary>
@@ -96,24 +114,25 @@ public partial class TkEndpointManager
             return status;
         }
 
-        if (!accountStates.TryGetValue(accountId, out var state))
-        {
-            // 如果账号没有状态记录,所有节点都是正常的
-            foreach ((var api_id, var api) in availableApis)
-            {
+        if (!accountStates.TryGetValue(accountId, out var state))
+        {
+            // 如果账号没有状态记录,所有节点都是正常的
+            foreach ((var api_id, var api) in availableApis)
+            {
                 status[api] = "正常";
-            }
-            return status;
-        }
-
-        foreach ((var api_id, var api) in availableApis)
-        {
-            if (IsEndpointSuspended(accountId, api))
-            {
-                // Redis没有挂起到期时间,展示"挂起"即可
-                status[api] = $"挂起";
-            }
-            else
+            }
+            return status;
+        }
+
+        var suspendStates = GetSuspendStates(accountId, availableApis.Values);
+        foreach ((var api_id, var api) in availableApis)
+        {
+            if (suspendStates.GetValueOrDefault(api))
+            {
+                // Redis没有挂起到期时间,展示"挂起"即可
+                status[api] = $"挂起";
+            }
+            else
             {
                 status[api] = "正常";
             }
@@ -126,18 +145,19 @@ public partial class TkEndpointManager
         var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
         if (endpoints == null || !endpoints.Any(e => e.status)) return (0, string.Empty);
 
-        // 1. 过滤可用端点(状态正常)
-        var availableApis = endpoints.Where(e => e.status);
-
-        var state = accountStates.GetOrAdd(accountId, _ => new AccountEndpointState());
-        var now = DateTime.Now;
-
-        // 2. 先找出所有未被挂起、未达限制的端点
-        var availableEndpoints = availableApis
-            .Where(e => !IsEndpointSuspended(accountId, e.endpoint))
-            .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
-            .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
-            .ToArray();
+        // 1. 过滤可用端点(状态正常)
+        var availableApis = endpoints.Where(e => e.status);
+        var suspendStates = GetSuspendStates(accountId, availableApis.Select(e => e.endpoint));
+
+        var state = accountStates.GetOrAdd(accountId, _ => new AccountEndpointState());
+        var now = DateTime.Now;
+
+        // 2. 先找出所有未被挂起、未达限制的端点
+        var availableEndpoints = availableApis
+            .Where(e => !suspendStates.GetValueOrDefault(e.endpoint))
+            .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
+            .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
+            .ToArray();
 
         // 3. 如果存在可用端点,直接返回
         if (availableEndpoints.Length > 0)
@@ -154,27 +174,27 @@ public partial class TkEndpointManager
             return (selectedEndpoint.ep_id, selectedEndpoint.endpoint);
         }
 
-        // 4. 检查是否仅因时间间隔限制
-        var intervalLimitedEndpoints = availableApis
-            .Where(e => !IsEndpointSuspended(accountId, e.endpoint))
-            .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
-            .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
-            .Where(e => e.interval_seconds > 0 &&
-                       e.last_call_time != null &&
-                       (now - e.last_call_time).TotalSeconds < e.interval_seconds)
+        // 4. 检查是否仅因时间间隔限制
+        var intervalLimitedEndpoints = availableApis
+            .Where(e => !suspendStates.GetValueOrDefault(e.endpoint))
+            .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
+            .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
+            .Where(e => e.interval_seconds > 0 &&
+                       e.last_call_time != null &&
+                       (now - e.last_call_time).TotalSeconds < e.interval_seconds)
             .ToArray();
 
         if (intervalLimitedEndpoints.Length > 0)
         {
             // 如果是时间间隔限制,返回空字符串
             return (0, string.Empty);
-        }
-
-        // 5. 检查所有端点的状态
-        var allEndpointsSuspended = availableApis.All(e => IsEndpointSuspended(accountId, e.endpoint));
-        var allEndpointsLimited = availableApis.All(e =>
-            (e.hourly_calls_limit > 0 && e.current_hourly_calls >= e.hourly_calls_limit) ||
-            (e.daily_calls_limit > 0 && e.current_daily_calls >= e.daily_calls_limit));
+        }
+
+        // 5. 检查所有端点的状态
+        var allEndpointsSuspended = availableApis.All(e => suspendStates.GetValueOrDefault(e.endpoint));
+        var allEndpointsLimited = availableApis.All(e =>
+            (e.hourly_calls_limit > 0 && e.current_hourly_calls >= e.hourly_calls_limit) ||
+            (e.daily_calls_limit > 0 && e.current_daily_calls >= e.daily_calls_limit));
 
         // 如果所有端点都被挂起或达到限制,返回 "ALL"
         if (allEndpointsSuspended || allEndpointsLimited)
@@ -212,12 +232,12 @@ public partial class TkEndpointManager
         }
     }
 
-    public static async Task SuspendAsync(int accountId, string endpoint, SuspendReason reason = SuspendReason.Passive, int? customHoldMinutes = null)
-    {
-        var holdMinutes = await GetEndpointHoldMinutesAsync(accountId);
-        TimeSpan suspendDuration;
-        if (customHoldMinutes.HasValue)
-        {
+    public static async Task SuspendAsync(int accountId, string endpoint, SuspendReason reason = SuspendReason.Passive, int? customHoldMinutes = null, bool notifyOtherNodes = true)
+    {
+        var holdMinutes = await GetEndpointHoldMinutesAsync(accountId);
+        TimeSpan suspendDuration;
+        if (customHoldMinutes.HasValue)
+        {
             suspendDuration = TimeSpan.FromMinutes(customHoldMinutes.Value);
         }
         else
@@ -226,15 +246,37 @@ public partial class TkEndpointManager
             {
                 SuspendReason.HourlyLimit => CalculateHourlySuspendDuration(),
                 SuspendReason.DailyLimit => CalculateDailySuspendDuration(),
-                _ => TimeSpan.FromMinutes(holdMinutes.GetValueOrDefault(endpoint, 240))
-            };
-        }
-
-        RedisHelper.Set($"tk_suspend:{accountId}:{endpoint}", 1, (int)suspendDuration.TotalSeconds);
-
-        _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}_{endpoint}", reason.ToString()).SaveAsync();
-        _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId}_{endpoint} ({reason})");
-    }
+                _ => TimeSpan.FromMinutes(holdMinutes.GetValueOrDefault(endpoint, 240))
+            };
+        }
+
+        await SuspendForDurationAsync(accountId, endpoint, suspendDuration, reason, notifyOtherNodes);
+    }
+
+    public static async Task SuspendForDurationAsync(int accountId, string endpoint, TimeSpan suspendDuration, SuspendReason reason = SuspendReason.Passive, bool notifyOtherNodes = true)
+    {
+        if (string.IsNullOrWhiteSpace(endpoint)) return;
+
+        string key = BuildSuspendKey(accountId, endpoint);
+        CacheSuspendState(key, true, suspendDuration);
+
+        try
+        {
+            RedisHelper.Set(key, 1, (int)Math.Ceiling(suspendDuration.TotalSeconds));
+        }
+        catch (Exception)
+        {
+            // 本地状态已写入,Redis 持久化失败时只降级,不阻塞主流程
+        }
+
+        if (notifyOtherNodes)
+        {
+            _ = EndPointCore.NotifyChangeSuspend(accountId, endpoint, release: false, durationSeconds: (int)Math.Ceiling(suspendDuration.TotalSeconds));
+        }
+
+        _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}_{endpoint}", reason.ToString()).SaveAsync();
+        _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId}_{endpoint} ({reason})");
+    }
 
 
     /// <summary>
@@ -242,25 +284,52 @@ public partial class TkEndpointManager
     /// </summary>
     /// <param name="accountId">账号ID</param>
     /// <param name="endpoint">要释放的端点名称,如果为null则释放所有端点的挂起状态</param>
-    public static void ReleaseSuspend(int accountId, string endpoint = null)
-    {
-        if (endpoint == null)
-        {
-            var pattern = $"tk_suspend:{accountId}:*";
-            var keys = RedisHelper.Keys(pattern);
-            foreach (var key in keys)
-            {
-                RedisHelper.Del(key);
-            }
-        }
-        else
-        {
-            RedisHelper.Del($"tk_suspend:{accountId}:{endpoint}");
-        }
-
-        var action = endpoint == null ? "释放所有挂起" : $"释放挂起({endpoint})";
-        _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}", action).SaveAsync();
-        _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId} {action}");
+    public static void ReleaseSuspend(int accountId, string endpoint = null, bool notifyOtherNodes = true)
+    {
+        if (endpoint == null)
+        {
+            foreach (var key in suspendStateCache.Keys.Where(key => key.StartsWith($"tk_suspend:{accountId}:", StringComparison.Ordinal)))
+            {
+                suspendStateCache.TryRemove(key, out _);
+            }
+
+            try
+            {
+                var pattern = $"tk_suspend:{accountId}:*";
+                var keys = RedisHelper.Keys(pattern);
+                foreach (var key in keys)
+                {
+                    RedisHelper.Del(key);
+                }
+            }
+            catch (Exception)
+            {
+                // Redis 不可用时只清理本地状态
+            }
+        }
+        else
+        {
+            string key = BuildSuspendKey(accountId, endpoint);
+            CacheSuspendState(key, false, SuspendStateCacheDuration);
+
+            try
+            {
+                RedisHelper.Del(key);
+            }
+            catch (Exception)
+            {
+                // Redis 不可用时只清理本地状态
+            }
+        }
+
+        if (notifyOtherNodes)
+        {
+            _ = EndPointCore.NotifyChangeSuspend(accountId, endpoint, release: true);
+        }
+
+        var action = endpoint == null ? "释放所有挂起" : $"释放挂起({endpoint})";
+        _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}", action).SaveAsync();
+        _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId} {action}");
     }
 
 
@@ -301,13 +370,80 @@ public partial class TkEndpointManager
         DailyLimit
     }
 
-    private class AccountEndpointState
-    {
-        // 只保留轮询索引
-        private readonly ConcurrentDictionary<string, int> roundRobinIndices = new();
-        public int GetOrAddAccountIndex(string key) => roundRobinIndices.GetOrAdd(key, -1);
-        public void UpdateAccountIndex(string key, int newIndex) => roundRobinIndices[key] = newIndex;
-    }
-
-
-}
+    private class AccountEndpointState
+    {
+        // 只保留轮询索引
+        private readonly ConcurrentDictionary<string, int> roundRobinIndices = new();
+        public int GetOrAddAccountIndex(string key) => roundRobinIndices.GetOrAdd(key, -1);
+        public void UpdateAccountIndex(string key, int newIndex) => roundRobinIndices[key] = newIndex;
+    }
+
+    private static string BuildSuspendKey(int accountId, string endpoint) => $"tk_suspend:{accountId}:{endpoint}";
+
+    private static Dictionary<string, bool> GetSuspendStates(int accountId, IEnumerable<string> endpoints)
+    {
+        var result = new Dictionary<string, bool>(StringComparer.Ordinal);
+        foreach (var endpoint in endpoints.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct(StringComparer.Ordinal))
+        {
+            result[endpoint] = IsEndpointSuspended(accountId, endpoint);
+        }
+        return result;
+    }
+
+    private static bool TryGetCachedSuspendState(string key, out bool isSuspended)
+    {
+        if (suspendStateCache.TryGetValue(key, out var entry))
+        {
+            if (entry.ExpiresAtUtc > DateTime.UtcNow)
+            {
+                isSuspended = entry.IsSuspended;
+                return true;
+            }
+
+            suspendStateCache.TryRemove(key, out _);
+        }
+
+        isSuspended = false;
+        return false;
+    }
+
+    private static void CacheSuspendState(string key, bool isSuspended, TimeSpan duration)
+    {
+        if (duration <= TimeSpan.Zero)
+        {
+            suspendStateCache.TryRemove(key, out _);
+            return;
+        }
+
+        suspendStateCache[key] = new SuspendCacheEntry(isSuspended, DateTime.UtcNow.Add(duration));
+    }
+
+    private static Task RefreshSuspendStateAsync(string key)
+    {
+        if (!suspendStateRefreshInFlight.TryAdd(key, 0))
+            return Task.CompletedTask;
+
+        return Task.Run(() =>
+        {
+            try
+            {
+                bool isSuspended = RedisHelper.Exists(key);
+                CacheSuspendState(
+                    key,
+                    isSuspended,
+                    isSuspended ? SuspendStateCacheDuration : SuspendStateFailureBackoffDuration);
+            }
+            catch (Exception)
+            {
+                CacheSuspendState(key, false, SuspendStateFailureBackoffDuration);
+            }
+            finally
+            {
+                suspendStateRefreshInFlight.TryRemove(key, out _);
+            }
+        });
+    }
+
+    private readonly record struct SuspendCacheEntry(bool IsSuspended, DateTime ExpiresAtUtc);
+
+}

+ 83 - 61
molilian.core/Core/taoke/TkPoolCore.cs

@@ -104,57 +104,39 @@ namespace molilian.core
             {
                 list = list.Where(item => riskStrategy.Equals(item.riskStrategy) && item.launchScene == launchScene).ToList();
             }
-            else
-            {
-                list = list.Where(item => string.IsNullOrEmpty(item.riskStrategy)).ToList();
-            }
-            if (!list.Any()) return null;
-
-
-
-            var filteredList = new List<TkPoolDTO>();
-            foreach (var item in list)
-            {
-                // 在所有账号中检查是否有账号关联了当前账号
-                var ownerAccount = allAccounts.FirstOrDefault(other =>
-                    other.id != item.id &&
-                    !string.IsNullOrEmpty(other.related_account_ids) &&
-                    other.related_account_ids.Split(',').Contains(item.id.ToString()));
-
-                // 如果没有账号关联它,或者关联它的账号在线,或者主账号不需要补点击,则可以使用
-                if (ownerAccount == null || list.Any(a => a.id == ownerAccount.id) || !ownerAccount.enable_fake_click)
-                {
-                    filteredList.Add(item);
-                }
-            }
-
-            foreach (var item in list)
-            {
-                // 在所有账号中检查是否有账号关联了当前账号
-                var ownerAccount = allAccounts.FirstOrDefault(other =>
-                    other.id != item.id &&
-                    !string.IsNullOrEmpty(other.related_account_ids) &&
-                    other.related_account_ids.Split(',').Contains(item.id.ToString()));
-
-                // 如果没有账号关联它,或者关联它的账号在线,或者主账号不需要补点击,则可以使用
-                if (ownerAccount == null || list.Any(a => a.id == ownerAccount.id) || !ownerAccount.enable_fake_click)
-                {
-                    filteredList.Add(item);
-                }
-            }
-
-            if (filteredList.Count == 0) return null;
+            else
+            {
+                list = list.Where(item => string.IsNullOrEmpty(item.riskStrategy)).ToList();
+            }
+            if (!list.Any()) return null;
+
+            list = FilterByAction(list, action).ToList();
+            if (!list.Any()) return null;
+
+            list = list.Where(item => PassesFastAccountChecks(item, action)).ToList();
+            if (!list.Any()) return null;
+
+            var ownerByRelatedId = BuildOwnerByRelatedAccountId(allAccounts);
+            var onlineAccountIds = list.Select(a => a.id).ToHashSet();
+            var filteredList = list
+                .Where(item =>
+                    !ownerByRelatedId.TryGetValue(item.id, out var ownerAccount) ||
+                    onlineAccountIds.Contains(ownerAccount.id) ||
+                    !ownerAccount.enable_fake_click)
+                .ToList();
+
+            if (filteredList.Count == 0) return null;
 
             // 1. 筛选出有可用端点的账号池,并计算权重
             var weightedAccounts = new List<(TkPoolDTO account, int weight)>();
             var random = new Random(Guid.NewGuid().GetHashCode()); // 避免重复种子问题
 
-            foreach (var item in filteredList)
-            {
-                // 获取账号关联的所有端点配置
-                var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(item.id, isTaobaoUrl, item.parseEndpoint);
-                if (endpoints == null || endpoints.Count == 0)
-                    continue;
+            foreach (var item in filteredList)
+            {
+                // 获取账号关联的所有端点配置
+                var endpoints = await TkEndpointCore.GetEndpointsByAccountReadonlyAsync(item.id, isTaobaoUrl, item.parseEndpoint).ConfigureAwait(false);
+                if (endpoints == null || endpoints.Count == 0)
+                    continue;
 
                 // 过滤可用端点(状态正常且未达限制)
                 var availableEndpoints = endpoints
@@ -203,17 +185,9 @@ namespace molilian.core
             return null;
         }
 
-        private static async Task<bool> FilterNodesAsync(TkPoolDTO item, TkAction action)
-        {
-            if (!string.IsNullOrEmpty(item.suspended_endpoint) && item.suspended_endpoint.Contains($"{_end_point}|")) return false;
-
-            if (!string.IsNullOrEmpty(_end_point) && !string.IsNullOrEmpty(item.end_point))
-            {
-                if (item.end_point != _end_point) return false;
-            }
-
-            // 使用初始化参数创建工作时间表
-            if (!new WorkSchedule(item.time_range).IsWorkHour()) return false;
+        private static async Task<bool> FilterNodesAsync(TkPoolDTO item, TkAction action)
+        {
+            if (!PassesFastAccountChecks(item, action)) return false;
 
             if (item.daily_calls_limit > 0)
             {
@@ -244,10 +218,58 @@ namespace molilian.core
                     break;
             }
 
-            if (item.daily_income_limit == 0) return true;
-            decimal income_amt = RiskControlCore.GetIncomeAmt(TkChannelEnum.tb, $"{item.id}");
-            return income_amt < item.daily_income_limit;
-        }
+            if (item.daily_income_limit == 0) return true;
+            decimal income_amt = RiskControlCore.GetIncomeAmt(TkChannelEnum.tb, $"{item.id}");
+            return income_amt < item.daily_income_limit;
+        }
+
+        private static IEnumerable<TkPoolDTO> FilterByAction(IEnumerable<TkPoolDTO> accounts, TkAction action)
+        {
+            return action switch
+            {
+                TkAction.parse => accounts.Where(item => item.enable_parse),
+                TkAction.promotionQuery => accounts.Where(item => item.enable_promotionQuery),
+                TkAction.coupon => accounts.Where(item => item.enable_coupon),
+                TkAction.activity => accounts.Where(item => item.enable_coupon),
+                _ => accounts
+            };
+        }
+
+        private static bool PassesFastAccountChecks(TkPoolDTO item, TkAction action)
+        {
+            if (!string.IsNullOrEmpty(item.suspended_endpoint) && item.suspended_endpoint.Contains($"{_end_point}|")) return false;
+
+            if (!string.IsNullOrEmpty(_end_point) && !string.IsNullOrEmpty(item.end_point) && item.end_point != _end_point)
+                return false;
+
+            if (!new WorkSchedule(item.time_range).IsWorkHour()) return false;
+
+            return action switch
+            {
+                TkAction.parse => item.enable_parse,
+                TkAction.promotionQuery => item.enable_promotionQuery,
+                TkAction.coupon => item.enable_coupon,
+                TkAction.activity => item.enable_coupon,
+                _ => true
+            };
+        }
+
+        private static Dictionary<int, TkPoolDTO> BuildOwnerByRelatedAccountId(IEnumerable<TkPoolDTO> allAccounts)
+        {
+            var result = new Dictionary<int, TkPoolDTO>();
+            foreach (var account in allAccounts)
+            {
+                if (string.IsNullOrWhiteSpace(account.related_account_ids)) continue;
+
+                foreach (var idStr in account.related_account_ids.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+                {
+                    if (!int.TryParse(idStr, out var relatedId)) continue;
+                    result.TryAdd(relatedId, account);
+                }
+            }
+
+            return result;
+        }
 
 
         public static async Task<IEnumerable<TkPoolDTO>> AllListAsync(bool force = false)

+ 114 - 59
molilian.core/Core/taoke/UnionParseCore/UnionParseCore.cs

@@ -15,11 +15,20 @@ namespace molilian.core
 {
     public partial class UnionParseCore
     {
+        private static readonly HashSet<string> BusyLimitedRiskStrategies = new(StringComparer.OrdinalIgnoreCase)
+        {
+            "tbpush",
+            "icon"
+        };
+
+        private static readonly int BusyLimitedWindowSeconds = GetIntEnv("TbPushIconThrottleWindowSeconds", 5);
+        private static readonly int BusyLimitedMaxRequests = GetIntEnv("TbPushIconThrottleMaxRequests", 100);
+
         /// <summary>
         /// 联盟转链统一入口
         /// </summary>
         public static async Task<ActionResult> UnionParseAsync(UnionParseRequest request)
-        {
+        {
 
             switch (request.Channel)
             {
@@ -37,20 +46,27 @@ namespace molilian.core
                 case "tbpush":
                 case "brwsimilar":
                 case "icon":
-                    if (request.LaunchScene == -1) request.LaunchScene = 0;
-                    break;
-            }
-
-            if (request.SpecialText == 1)
-            {
-                return await SpecialTextParseAsync(request);
-            }
-
-            request.Content = request.Content.Trim();
-            switch (request.Type)
-            {
-                case "dp":
-                    return request.Channel switch
+                    if (request.LaunchScene == -1) request.LaunchScene = 0;
+                    break;
+            }
+
+            request.Content = request.Content.Trim();
+
+            var busyRejectResult = TryRejectBusyLimitedRequest(request);
+            if (busyRejectResult != null)
+            {
+                return busyRejectResult;
+            }
+
+            if (request.SpecialText == 1)
+            {
+                return await SpecialTextParseAsync(request);
+            }
+
+            switch (request.Type)
+            {
+                case "dp":
+                    return request.Channel switch
                     {
                         "jd" => await DeeplinkJdParseAsync(request),
                         "pdd" => await DeeplinkPddParseAsync(request),
@@ -195,23 +211,61 @@ namespace molilian.core
             return success;
         }
 
-        private static APIResult TaobaoParseOutput(TkDataDTO result, Dictionary<string, long> swData, object info, APIResultCodeEnum code = APIResultCodeEnum.OK)
-        {
-            if (result.elapsedTime > 2000)
-            {
-                LoggerLibrary log = new("debug", "stopwatch");
+        private static APIResult TaobaoParseOutput(TkDataDTO result, Dictionary<string, long> swData, object info, APIResultCodeEnum code = APIResultCodeEnum.OK)
+        {
+            if (result.elapsedTime > 2000)
+            {
+                LoggerLibrary log = new("debug", "stopwatch");
                 foreach ((var key, var value) in swData)
                 {
                     log.Info($"{key}\t{value}");
                 }
                 log.SaveAsync();
-            }
-            return new APIResult(info, code);
-        }
-
-
-        public static async Task<APIResult> DeepleaperParseAsync(UnionParseRequest request)
-        {
+            }
+            return new APIResult(info, code);
+        }
+
+        private static ActionResult? TryRejectBusyLimitedRequest(UnionParseRequest request)
+        {
+            if (!"pdd".Equals(request.Channel, StringComparison.OrdinalIgnoreCase))
+            {
+                return null;
+            }
+
+            if (!BusyLimitedRiskStrategies.Contains(request.RiskStrategy))
+            {
+                return null;
+            }
+
+            var window = TimeSpan.FromSeconds(BusyLimitedWindowSeconds);
+            var bucketKey = $"pdd_{request.RiskStrategy.ToLowerInvariant()}";
+            if (SpecialBusinessRateLimiter.TryAcquire(bucketKey, BusyLimitedMaxRequests, window))
+            {
+                return null;
+            }
+
+            var result = PddUnionPlus.GetFormattedObject(request);
+            result.link_type = LinkTypeEnum.unknown;
+            result.channel_type = ChannelTypeEnum.pdd;
+            result.rawContent = request.Content;
+            result.success = false;
+            result.message = "放弃转链";
+            result.reason = "系统繁忙";
+            result.subCode = (int)TkSubCodeEnum.TrafficCtrl;
+            result.deeplink_url = string.IsNullOrEmpty(result.shortLinkurl) ? string.Empty : PddUnionPlus.GetDeeplink(result.shortLinkurl);
+            _ = TkLogCore.ParseLogAsync(result);
+            return PddParseOutput(result);
+        }
+
+        private static int GetIntEnv(string key, int defaultValue)
+        {
+            var raw = Environment.GetEnvironmentVariable(key);
+            return int.TryParse(raw, out var value) && value > 0 ? value : defaultValue;
+        }
+
+
+        public static async Task<APIResult> DeepleaperParseAsync(UnionParseRequest request)
+        {
             //content = content.UrlDecode();
             var result = AlimamaPlus.GetFormattedObject(request);
             result.shortLinkurl = string.Empty;
@@ -737,41 +791,42 @@ namespace molilian.core
                     break;
             }
 
-            if ("brwsimilar".Equals(request.RiskStrategy))
-            {
-                return TaobaoParseOutput(result, swData, new
-                {
-                    success = ValidateDeeplinkIfRequired(result.success, result.deeplink_url),
-                    result.commercial,
-                    exposeTracks = TracksCore.BuildBrwSimilarPath(TrackType.Expose, result),
+            if ("brwsimilar".Equals(request.RiskStrategy))
+            {
+                bool clearGoodsFields = !result.success;
+                return TaobaoParseOutput(result, swData, new
+                {
+                    success = ValidateDeeplinkIfRequired(result.success, result.deeplink_url),
+                    result.commercial,
+                    exposeTracks = TracksCore.BuildBrwSimilarPath(TrackType.Expose, result),
                     clickTracks = TracksCore.BuildBrwSimilarPath(TrackType.Click, result),
                     result.with_middle_page,
                     result.message,
-                    sub_message = result.reason,
-                    home_page = "tbopen://m.taobao.com/tbopen/index.html".Equals(result.deeplink_url),
-                    link_type = result.link_type.ToString(),
-                    channel = result?.channel.ToString(),
-                    result.channel_type,
-                    result.shopTitle,
-                    result.itemId,
-                    result.itemName,
-                    result.pic,
-                    price = result.reservePrice,
-                    result.zkFinalPrice,
-                    result.promotionPrice,
-                    result.couponAmount,
-                    result.couponEffectiveStartTime,
-                    result?.couponEffectiveEndTime,
-                    result.shortLinkurl,
-                    result.deeplink_url,
-                    result.riskStrategy,
-                    result.launchScene,
-                    result.content,
-                    result.taoToken,
-                    other_aff,
-                    promotionImg = result.PromotionImg
-                });
-            }
+                    sub_message = result.reason,
+                    home_page = "tbopen://m.taobao.com/tbopen/index.html".Equals(result.deeplink_url),
+                    link_type = result.link_type.ToString(),
+                    channel = result?.channel.ToString(),
+                    result.channel_type,
+                    shopTitle = clearGoodsFields ? string.Empty : result.shopTitle,
+                    itemId = clearGoodsFields ? string.Empty : result.itemId,
+                    itemName = clearGoodsFields ? string.Empty : result.itemName,
+                    pic = clearGoodsFields ? string.Empty : result.pic,
+                    price = clearGoodsFields ? string.Empty : (object)result.reservePrice,
+                    zkFinalPrice = clearGoodsFields ? string.Empty : (object)result.zkFinalPrice,
+                    promotionPrice = clearGoodsFields ? string.Empty : (object)result.promotionPrice,
+                    couponAmount = clearGoodsFields ? string.Empty : (object)result.couponAmount,
+                    couponEffectiveStartTime = clearGoodsFields ? string.Empty : result.couponEffectiveStartTime,
+                    couponEffectiveEndTime = clearGoodsFields ? string.Empty : result.couponEffectiveEndTime,
+                    result.shortLinkurl,
+                    result.deeplink_url,
+                    result.riskStrategy,
+                    result.launchScene,
+                    result.content,
+                    result.taoToken,
+                    other_aff,
+                    promotionImg = clearGoodsFields ? string.Empty : (object)result.PromotionImg
+                });
+            }
             return TaobaoParseOutput(result, swData, new
             {
                 success = ValidateDeeplinkIfRequired(result.success, result.deeplink_url),

+ 41 - 0
molilian.core/Plus/SpecialBusinessRateLimiter.cs

@@ -0,0 +1,41 @@
+using System.Collections.Concurrent;
+
+namespace molilian.core
+{
+    public static class SpecialBusinessRateLimiter
+    {
+        private static readonly ConcurrentDictionary<string, SlidingWindowState> States = new();
+
+        public static bool TryAcquire(string bucketKey, int limit, TimeSpan window)
+        {
+            if (string.IsNullOrWhiteSpace(bucketKey)) return true;
+            if (limit <= 0 || window <= TimeSpan.Zero) return true;
+
+            var state = States.GetOrAdd(bucketKey, _ => new SlidingWindowState());
+            var nowTicks = DateTime.UtcNow.Ticks;
+            var minTicks = nowTicks - window.Ticks;
+
+            lock (state.SyncRoot)
+            {
+                while (state.Timestamps.Count > 0 && state.Timestamps.Peek() < minTicks)
+                {
+                    state.Timestamps.Dequeue();
+                }
+
+                if (state.Timestamps.Count >= limit)
+                {
+                    return false;
+                }
+
+                state.Timestamps.Enqueue(nowTicks);
+                return true;
+            }
+        }
+
+        private sealed class SlidingWindowState
+        {
+            public object SyncRoot { get; } = new();
+            public Queue<long> Timestamps { get; } = new();
+        }
+    }
+}

+ 104 - 41
molilian.core/Plus/SystemMonitor.cs

@@ -4,26 +4,35 @@ using System.Diagnostics;
 using System.Text;
 
 
-namespace molilian.core
-{
-    public static class SystemMonitor
-    {
-        private static Timer _monitorTimer;
-        public static void StartMonitoring(int intervalSeconds = 30)
-        {
-            _monitorTimer = new Timer(
-                CollectMetrics,
-                null,
+namespace molilian.core
+{
+    public static class SystemMonitor
+    {
+        private static Timer _monitorTimer;
+        private static int _isCollecting;
+        private static readonly AlertState MemoryAlertState = new();
+        private static readonly int MemoryAlertThresholdMb = GetIntEnv("SystemMonitorMemoryAlertMB", 2048);
+        private static readonly int MemoryAlertRecoveryMb = GetIntEnv("SystemMonitorMemoryRecoveryMB", (int)(MemoryAlertThresholdMb * 0.9));
+        private static readonly int MemoryAlertConsecutiveHits = GetIntEnv("SystemMonitorMemoryAlertConsecutiveHits", 3);
+        private static readonly int MemoryAlertCooldownMinutes = GetIntEnv("SystemMonitorMemoryAlertCooldownMinutes", 30);
+
+        public static void StartMonitoring(int intervalSeconds = 30)
+        {
+            _monitorTimer = new Timer(
+                CollectMetrics,
+                null,
                 TimeSpan.Zero,
                 TimeSpan.FromSeconds(intervalSeconds)
             );
         }
 
-        private static void CollectMetrics(object state)
-        {
-            try
-            {
-                var metrics = new StringBuilder();
+        private static void CollectMetrics(object state)
+        {
+            if (Interlocked.Exchange(ref _isCollecting, 1) == 1) return;
+
+            try
+            {
+                var metrics = new StringBuilder();
 
                 // 线程池信息
                 ThreadPool.GetMaxThreads(out int maxWorkerThreads, out int maxIoThreads);
@@ -64,12 +73,16 @@ namespace molilian.core
                     ThreadPool.PendingWorkItemCount
                 );
             }
-            catch (Exception ex)
-            {
-                _ = new LoggerLibrary("debug", "metrics_error")
-                    .Info($"Error collecting system metrics: {ex}").SaveAsync();
-            }
-        }
+            catch (Exception ex)
+            {
+                _ = new LoggerLibrary("debug", "metrics_error")
+                    .Info($"Error collecting system metrics: {ex}").SaveAsync();
+            }
+            finally
+            {
+                Volatile.Write(ref _isCollecting, 0);
+            }
+        }
 
         private static void CheckAlertConditions(
             int usedWorkerThreads,
@@ -80,21 +93,17 @@ namespace molilian.core
             var alerts = new List<string>();
 
             // 线程池使用率超过80%
-            if ((double)usedWorkerThreads / maxWorkerThreads > 0.8)
-            {
-                alerts.Add($"High thread pool usage: {usedWorkerThreads}/{maxWorkerThreads}");
-            }
-
-            // 内存使用超过2GB
-            if (workingSet > 2L * 1024 * 1024 * 1024)
-            {
-                alerts.Add($"High memory usage: {workingSet / 1024 / 1024} MB");
-            }
-
-            // 线程池队列堆积
-            if (pendingWork > 100)
-            {
-                alerts.Add($"High thread pool queue length: {pendingWork}");
+            if ((double)usedWorkerThreads / maxWorkerThreads > 0.8)
+            {
+                alerts.Add($"High thread pool usage: {usedWorkerThreads}/{maxWorkerThreads}");
+            }
+
+            HandleMemoryAlert(workingSet);
+
+            // 线程池队列堆积
+            if (pendingWork > 100)
+            {
+                alerts.Add($"High thread pool queue length: {pendingWork}");
             }
 
             if (alerts.Any())
@@ -104,8 +113,62 @@ namespace molilian.core
         }
 
         public static void StopMonitoring()
-        {
-            _monitorTimer?.Dispose();
-        }
-    }
-}
+        {
+            _monitorTimer?.Dispose();
+        }
+
+        private static void HandleMemoryAlert(long workingSet)
+        {
+            int workingSetMb = (int)(workingSet / 1024 / 1024);
+            bool isTriggered = workingSetMb >= MemoryAlertThresholdMb;
+            bool isRecovered = workingSetMb <= MemoryAlertRecoveryMb;
+            var now = DateTime.UtcNow;
+
+            lock (MemoryAlertState)
+            {
+                if (isTriggered)
+                {
+                    MemoryAlertState.ConsecutiveHits++;
+
+                    bool shouldSend = MemoryAlertState.ConsecutiveHits >= MemoryAlertConsecutiveHits &&
+                        (!MemoryAlertState.IsActive || now - MemoryAlertState.LastSentAtUtc >= TimeSpan.FromMinutes(MemoryAlertCooldownMinutes));
+
+                    if (shouldSend)
+                    {
+                        NotifyCore.Notify(
+                            $"System alerts:\nHigh memory usage: {workingSetMb} MB\n" +
+                            $"threshold={MemoryAlertThresholdMb} MB, consecutiveHits={MemoryAlertState.ConsecutiveHits}");
+                        MemoryAlertState.IsActive = true;
+                        MemoryAlertState.LastSentAtUtc = now;
+                    }
+
+                    return;
+                }
+
+                MemoryAlertState.ConsecutiveHits = 0;
+
+                if (MemoryAlertState.IsActive && isRecovered)
+                {
+                    NotifyCore.Notify(
+                        $"System recovery:\nMemory usage recovered to {workingSetMb} MB\n" +
+                        $"recoveryThreshold={MemoryAlertRecoveryMb} MB");
+                    MemoryAlertState.IsActive = false;
+                    MemoryAlertState.LastSentAtUtc = now;
+                }
+            }
+        }
+
+        private static int GetIntEnv(string key, int defaultValue)
+        {
+            var raw = Environment.GetEnvironmentVariable(key);
+            return int.TryParse(raw, out var value) && value > 0 ? value : defaultValue;
+        }
+
+        private sealed class AlertState
+        {
+            public int ConsecutiveHits { get; set; }
+            public bool IsActive { get; set; }
+            public DateTime LastSentAtUtc { get; set; } = DateTime.MinValue;
+        }
+    }
+}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません