2
0

2 Commits c1cda190c8 ... b2e9cd8710

Autor SHA1 Mensagem Data
  dodo hold b2e9cd8710 ✨ feat(报告): 增加请求量字段并优化登录时间显示 há 1 mês atrás
  dodo hold 09e2210923 0711 há 1 mês atrás

+ 21 - 8
molilian.api/Controllers/admin/DeeplinkReportController.cs

@@ -79,12 +79,14 @@ namespace molilian.api.Controllers
                 }
 
                 var reportDate = date.ToString("yyyy-MM-dd");
+                var accountTotalCount = await TkLogCore.GetTotalAsync($":parse_total:{accountName}:{dateKey}");
                 var row = new DeeplinkDailyReportRow
                 {
                     row_key = reportDate,
                     row_type = "daily",
                     report_date = reportDate,
-                    total_count = await TkLogCore.GetTotalAsync($":parse_total:{accountName}:{dateKey}"),
+                    request_count = accountTotalCount,
+                    total_count = accountTotalCount,
                     success_count = await TkLogCore.GetTotalAsync($":parse_total:{accountName}:success:{dateKey}"),
                     fail_count = await TkLogCore.GetTotalAsync($":parse_total:{accountName}:fail:{dateKey}")
                 };
@@ -96,6 +98,7 @@ namespace molilian.api.Controllers
                         report_date = item.display_name,
                         channel_name = item.channel_name,
                         display_name = item.display_name,
+                        request_count = item.request_count,
                         total_count = item.total_count,
                         success_count = item.success_count,
                         fail_count = item.fail_count
@@ -113,6 +116,7 @@ namespace molilian.api.Controllers
                 {
                     channel_name = item.Key,
                     display_name = GetDisplayName(reportChannels, item.Key),
+                    request_count = item.Value,
                     total_count = item.Value
                 })
                 .ToList();
@@ -141,6 +145,7 @@ namespace molilian.api.Controllers
 
             var summary = new DeeplinkDailyReportSummary
             {
+                request_count = list.Sum(item => item.request_count),
                 total_count = list.Sum(item => item.total_count),
                 success_count = list.Sum(item => item.success_count),
                 fail_count = list.Sum(item => item.fail_count)
@@ -245,14 +250,19 @@ namespace molilian.api.Controllers
             string dateKey
         )
         {
-            var tasks = channels.Select(async channel => new
-            DeeplinkDailyReportChannel
+            var tasks = channels.Select(async channel =>
             {
-                channel_name = channel.channel_name,
-                display_name = channel.display_name,
-                total_count = await TkLogCore.GetTotalAsync($":parse_total:{channel.channel_name}:{dateKey}"),
-                success_count = await TkLogCore.GetTotalAsync($":parse_total:{channel.channel_name}:success:{dateKey}"),
-                fail_count = await TkLogCore.GetTotalAsync($":parse_total:{channel.channel_name}:fail:{dateKey}")
+                var channelTotalCount = await TkLogCore.GetTotalAsync($":parse_total:{channel.channel_name}:{dateKey}");
+
+                return new DeeplinkDailyReportChannel
+                {
+                    channel_name = channel.channel_name,
+                    display_name = channel.display_name,
+                    request_count = channelTotalCount,
+                    total_count = channelTotalCount,
+                    success_count = await TkLogCore.GetTotalAsync($":parse_total:{channel.channel_name}:success:{dateKey}"),
+                    fail_count = await TkLogCore.GetTotalAsync($":parse_total:{channel.channel_name}:fail:{dateKey}")
+                };
             });
 
             var results = await Task.WhenAll(tasks);
@@ -264,6 +274,7 @@ namespace molilian.api.Controllers
     {
         public string channel_name { get; set; } = string.Empty;
         public string display_name { get; set; } = string.Empty;
+        public long request_count { get; set; } = 0;
         public long total_count { get; set; } = 0;
         public long success_count { get; set; } = 0;
         public long fail_count { get; set; } = 0;
@@ -276,6 +287,7 @@ namespace molilian.api.Controllers
         public string report_date { get; set; } = string.Empty;
         public string channel_name { get; set; } = string.Empty;
         public string display_name { get; set; } = string.Empty;
+        public long request_count { get; set; } = 0;
         public long total_count { get; set; } = 0;
         public long success_count { get; set; } = 0;
         public long fail_count { get; set; } = 0;
@@ -284,6 +296,7 @@ namespace molilian.api.Controllers
 
     public class DeeplinkDailyReportSummary
     {
+        public long request_count { get; set; } = 0;
         public long total_count { get; set; } = 0;
         public long success_count { get; set; } = 0;
         public long fail_count { get; set; } = 0;

+ 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
             });
         }
 
 
     }
-}
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 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)

+ 606 - 577
molilian.core/Core/TracksCore.cs

@@ -1,143 +1,143 @@
 using System;
-using System.Collections.Generic;
-using System.Data;
-using System.Net;
-using System.Threading.Tasks;
-using Dapper;
-using CSRedis;
-using dodohold.core;
-using System.Text.Json;
-using YunhuiKit;
-
-namespace molilian.core
-{
-    public enum TrackType
-    {
-        Expose,
-        Click
-    }
-
-    public partial class TracksCore
+using System.Collections.Generic;
+using System.Data;
+using System.Net;
+using System.Threading.Tasks;
+using Dapper;
+using CSRedis;
+using dodohold.core;
+using System.Text.Json;
+using YunhuiKit;
+
+namespace molilian.core
+{
+    public enum TrackType
     {
-        private const string RedisPrefix = ":tracks_v123";
-        private const int DailyExpireSeconds = 40 * 86400;
-        private const int HourlyExpireSeconds = 7 * 86400; // keep a week of hourly buckets
-        private const string DefaultDimensionValue = "";
-        private const int LinkCacheExpireSeconds = 30 * 86400;
-        private const string LinkCacheVersion = "v2";
-        private const string TrackRequestLogKey = ":tracks:request:logs";
-        private static readonly HashSet<string> SupportEventTypes = new(StringComparer.OrdinalIgnoreCase) { "expose", "click" };
-
-
-        /// <summary>
-        /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links
-        /// </summary>
-        public static Task<TrackLinkDTO> CreateLinkAsync(string eventType, string typename, string scene, string uniqueId, string description = "", bool forceNew = false)
-        {
-            return CreateLinkAsync(eventType, string.Empty, typename, scene, uniqueId, description, forceNew);
-        }
-
-        /// <summary>
-        /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links
-        /// </summary>
-        public static Task<TrackLinkDTO> CreateLinkAsync(string eventType, string platform, string typename, string scene, string uniqueId, string description = "", bool forceNew = false)
-        {
-            eventType = NormalizeEventType(eventType);
-            platform = Normalize(platform);
-            typename = Normalize(typename);
-            scene = NormalizeOrAll(scene);
-            uniqueId = NormalizeOrAll(uniqueId);
-            description = Normalize(description);
-
-            if (!SupportEventTypes.Contains(eventType))
-            {
-                return Task.FromResult<TrackLinkDTO>(null);
-            }
-
-            string cacheKey = GetLinkCacheKey(eventType, platform, typename, scene, uniqueId);
-            if (!forceNew)
-            {
-                try
-                {
-                    int cachedId = RedisHelper.Get<int>(cacheKey);
-                    if (cachedId > 0)
-                    {
-                        var cachedLink = RedisHelper.Get<TrackLinkDTO>(GetLinkIdCacheKey(cachedId));
-                        if (cachedLink != null) return Task.FromResult(cachedLink);
-                    }
-                }
-                catch
-                {
-                    // ignore cache errors
-                }
-            }
-
-            try
-            {
-                using var conn = DBContext.GetOpenConnection();
-                TrackLinkDTO exist = null;
-                if (!forceNew)
-                {
-                    exist = new DBContext.Table(conn, "track_links")
-                        .Get<TrackLinkDTO>("event_type=@event_type AND platform=@platform AND typename=@typename AND scene=@scene AND unique_id=@unique_id",
-                            new { event_type = eventType, platform, typename, scene, unique_id = uniqueId });
-                }
-
-                if (exist != null)
-                {
-                    exist.description = description;
-                    string path = BuildPath(exist.id, uniqueId);
-                    if (string.IsNullOrEmpty(exist.path) || !exist.path.Contains("track_id"))
-                    {
-                        exist.path = path;
-                        new DBContext.Table(conn, "track_links")
-                            .Add("path", path)
-                            .Add("platform", platform)
-                            .Add("typename", typename)
-                            .Add("description", description)
-                            .Add("update_time", DateTime.Now)
-                            .Where("id=@id", new { exist.id })
-                            .Update();
-                    }
-                    _ = RedisHelper.Set(cacheKey, exist.id, LinkCacheExpireSeconds);
-                    _ = RedisHelper.Set(GetLinkIdCacheKey(exist.id), exist, LinkCacheExpireSeconds);
-                    return Task.FromResult(exist);
-                }
-
-                var item = new TrackLinkDTO
-                {
-                    event_type = eventType,
-                    platform = platform,
-                    typename = typename,
-                    scene = scene,
-                    unique_id = uniqueId,
-                    path = string.Empty,
-                    description = description,
-                    create_time = DateTime.Now,
-                    update_time = DateTime.Now
-                };
-
-                var id = conn.Insert(item);
-                if (id != null && int.TryParse(id.ToString(), out var linkId))
-                {
-                    item.id = linkId;
-                    item.path = BuildPath(linkId, uniqueId);
-                    new DBContext.Table(conn, "track_links")
-                        .Add("path", item.path)
-                        .Add("description", description)
-                        .Add("update_time", DateTime.Now)
-                        .Where("id=@id", new { item.id })
-                        .Update();
-                }
-                _ = RedisHelper.Set(cacheKey, item.id, LinkCacheExpireSeconds);
-                _ = RedisHelper.Set(GetLinkIdCacheKey(item.id), item, LinkCacheExpireSeconds);
-                return Task.FromResult(item);
-            }
-            catch (Exception ex)
-            {
-                _ = new LoggerLibrary("TracksCore", "CreateLink")
-                    .Info(ex.Message, ex.StackTrace)
-                    .SaveAsync();
+        Expose,
+        Click
+    }
+
+    public partial class TracksCore
+    {
+        private const string RedisPrefix = ":tracks_v123";
+        private const int DailyExpireSeconds = 40 * 86400;
+        private const int HourlyExpireSeconds = 7 * 86400; // keep a week of hourly buckets
+        private const string DefaultDimensionValue = "";
+        private const int LinkCacheExpireSeconds = 30 * 86400;
+        private const string LinkCacheVersion = "v2";
+        private const string TrackRequestLogKey = ":tracks:request:logs";
+        private static readonly HashSet<string> SupportEventTypes = new(StringComparer.OrdinalIgnoreCase) { "expose", "click" };
+
+
+        /// <summary>
+        /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links
+        /// </summary>
+        public static Task<TrackLinkDTO> CreateLinkAsync(string eventType, string typename, string scene, string uniqueId, string description = "", bool forceNew = false)
+        {
+            return CreateLinkAsync(eventType, string.Empty, typename, scene, uniqueId, description, forceNew);
+        }
+
+        /// <summary>
+        /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links
+        /// </summary>
+        public static Task<TrackLinkDTO> CreateLinkAsync(string eventType, string platform, string typename, string scene, string uniqueId, string description = "", bool forceNew = false)
+        {
+            eventType = NormalizeEventType(eventType);
+            platform = Normalize(platform);
+            typename = Normalize(typename);
+            scene = NormalizeOrAll(scene);
+            uniqueId = NormalizeOrAll(uniqueId);
+            description = Normalize(description);
+
+            if (!SupportEventTypes.Contains(eventType))
+            {
+                return Task.FromResult<TrackLinkDTO>(null);
+            }
+
+            string cacheKey = GetLinkCacheKey(eventType, platform, typename, scene, uniqueId);
+            if (!forceNew)
+            {
+                try
+                {
+                    int cachedId = RedisHelper.Get<int>(cacheKey);
+                    if (cachedId > 0)
+                    {
+                        var cachedLink = RedisHelper.Get<TrackLinkDTO>(GetLinkIdCacheKey(cachedId));
+                        if (cachedLink != null) return Task.FromResult(cachedLink);
+                    }
+                }
+                catch
+                {
+                    // ignore cache errors
+                }
+            }
+
+            try
+            {
+                using var conn = DBContext.GetOpenConnection();
+                TrackLinkDTO exist = null;
+                if (!forceNew)
+                {
+                    exist = new DBContext.Table(conn, "track_links")
+                        .Get<TrackLinkDTO>("event_type=@event_type AND platform=@platform AND typename=@typename AND scene=@scene AND unique_id=@unique_id",
+                            new { event_type = eventType, platform, typename, scene, unique_id = uniqueId });
+                }
+
+                if (exist != null)
+                {
+                    exist.description = description;
+                    string path = BuildPath(exist.id, uniqueId);
+                    if (string.IsNullOrEmpty(exist.path) || !exist.path.Contains("track_id"))
+                    {
+                        exist.path = path;
+                        new DBContext.Table(conn, "track_links")
+                            .Add("path", path)
+                            .Add("platform", platform)
+                            .Add("typename", typename)
+                            .Add("description", description)
+                            .Add("update_time", DateTime.Now)
+                            .Where("id=@id", new { exist.id })
+                            .Update();
+                    }
+                    _ = RedisHelper.Set(cacheKey, exist.id, LinkCacheExpireSeconds);
+                    _ = RedisHelper.Set(GetLinkIdCacheKey(exist.id), exist, LinkCacheExpireSeconds);
+                    return Task.FromResult(exist);
+                }
+
+                var item = new TrackLinkDTO
+                {
+                    event_type = eventType,
+                    platform = platform,
+                    typename = typename,
+                    scene = scene,
+                    unique_id = uniqueId,
+                    path = string.Empty,
+                    description = description,
+                    create_time = DateTime.Now,
+                    update_time = DateTime.Now
+                };
+
+                var id = conn.Insert(item);
+                if (id != null && int.TryParse(id.ToString(), out var linkId))
+                {
+                    item.id = linkId;
+                    item.path = BuildPath(linkId, uniqueId);
+                    new DBContext.Table(conn, "track_links")
+                        .Add("path", item.path)
+                        .Add("description", description)
+                        .Add("update_time", DateTime.Now)
+                        .Where("id=@id", new { item.id })
+                        .Update();
+                }
+                _ = RedisHelper.Set(cacheKey, item.id, LinkCacheExpireSeconds);
+                _ = RedisHelper.Set(GetLinkIdCacheKey(item.id), item, LinkCacheExpireSeconds);
+                return Task.FromResult(item);
+            }
+            catch (Exception ex)
+            {
+                _ = new LoggerLibrary("TracksCore", "CreateLink")
+                    .Info(ex.Message, ex.StackTrace)
+                    .SaveAsync();
                 return Task.FromResult<TrackLinkDTO>(null);
             }
         }
@@ -145,42 +145,42 @@ namespace molilian.core
         /// <summary>
         /// 曝光/点击触发:计入 Redis,失败不影响返回
         /// </summary>
-        public static Task<bool> TrackAsync(TrackLinkDTO link, string scene = "", int accountId = 0)
-        {
-
-            string dateStr = DateTime.Now.ToString("yyyyMMdd");
-            string hourStr = DateTime.Now.ToString("yyyyMMddHH");
-            string metricScene = ResolveMetricScene(link, scene);
-            accountId = Math.Max(accountId, 0);
-            string indexValue = BuildMetricIndexValue(link.event_type, link.id, metricScene);
-
-            var metrics = new List<(string key, string indexKey, string indexValue, int expire)>
-            {
-                (BuildDailyCountKey(link.event_type, link.id, dateStr, metricScene), $"{RedisPrefix}:daily:index:{dateStr}:track", indexValue, DailyExpireSeconds),
-                (BuildHourlyCountKey(link.event_type, link.id, hourStr, metricScene), $"{RedisPrefix}:hour:index:{hourStr}:track", indexValue, HourlyExpireSeconds),
-            };
-            if (accountId > 0)
-            {
-                string accountIndexValue = BuildMetricIndexValue(link.event_type, link.id, metricScene, accountId);
-                metrics.Add((BuildDailyCountKey(link.event_type, link.id, dateStr, metricScene, accountId), $"{RedisPrefix}:daily:index:{dateStr}:track", accountIndexValue, DailyExpireSeconds));
-                metrics.Add((BuildHourlyCountKey(link.event_type, link.id, hourStr, metricScene, accountId), $"{RedisPrefix}:hour:index:{hourStr}:track", accountIndexValue, HourlyExpireSeconds));
-            }
-
-            try
-            {
-                foreach (var metric in metrics)
-                {
-                    RedisHelper.IncrBy(metric.key);
-                    RedisHelper.Expire(metric.key, metric.expire);
-
-                    RedisHelper.SAdd(metric.indexKey, metric.indexValue);
-                    RedisHelper.Expire(metric.indexKey, metric.expire);
-                }
-                return Task.FromResult(true);
-            }
-            catch (Exception ex)
-            {
-                _ = new LoggerLibrary("TracksCore", "TrackAsync")
+        public static Task<bool> TrackAsync(TrackLinkDTO link, string scene = "", int accountId = 0)
+        {
+
+            string dateStr = DateTime.Now.ToString("yyyyMMdd");
+            string hourStr = DateTime.Now.ToString("yyyyMMddHH");
+            string metricScene = ResolveMetricScene(link, scene);
+            accountId = Math.Max(accountId, 0);
+            string indexValue = BuildMetricIndexValue(link.event_type, link.id, metricScene);
+
+            var metrics = new List<(string key, string indexKey, string indexValue, int expire)>
+            {
+                (BuildDailyCountKey(link.event_type, link.id, dateStr, metricScene), $"{RedisPrefix}:daily:index:{dateStr}:track", indexValue, DailyExpireSeconds),
+                (BuildHourlyCountKey(link.event_type, link.id, hourStr, metricScene), $"{RedisPrefix}:hour:index:{hourStr}:track", indexValue, HourlyExpireSeconds),
+            };
+            if (accountId > 0)
+            {
+                string accountIndexValue = BuildMetricIndexValue(link.event_type, link.id, metricScene, accountId);
+                metrics.Add((BuildDailyCountKey(link.event_type, link.id, dateStr, metricScene, accountId), $"{RedisPrefix}:daily:index:{dateStr}:track", accountIndexValue, DailyExpireSeconds));
+                metrics.Add((BuildHourlyCountKey(link.event_type, link.id, hourStr, metricScene, accountId), $"{RedisPrefix}:hour:index:{hourStr}:track", accountIndexValue, HourlyExpireSeconds));
+            }
+
+            try
+            {
+                foreach (var metric in metrics)
+                {
+                    RedisHelper.IncrBy(metric.key);
+                    RedisHelper.Expire(metric.key, metric.expire);
+
+                    RedisHelper.SAdd(metric.indexKey, metric.indexValue);
+                    RedisHelper.Expire(metric.indexKey, metric.expire);
+                }
+                return Task.FromResult(true);
+            }
+            catch (Exception ex)
+            {
+                _ = new LoggerLibrary("TracksCore", "TrackAsync")
                     .Info(ex.Message, ex.StackTrace)
                     .SaveAsync();
                 return Task.FromResult(false);
@@ -188,412 +188,441 @@ namespace molilian.core
         }
 
         /// <summary>
-        /// 日报:拉取指定日期 Redis 计数并落地到 track_daily_report,可高频重复执行。
+        /// 日报:拉取指定日期 Redis 计数并落地到 track_daily_report,可高频重复执行。
         /// </summary>
         public static async Task<int> FlushDailyAsync(DateTime targetDate)
         {
-            string dateStr = targetDate.ToString("yyyyMMdd");
-            string indexKey = $"{RedisPrefix}:daily:index:{dateStr}:track";
-
-            var indexMembers = await RedisHelper.SMembersAsync<string>(indexKey) ?? [];
-            if (indexMembers == null || indexMembers.Length == 0) return 0;
-
-            var buckets = new Dictionary<string, DailyReportBucket>();
-            using var conn = DBContext.GetOpenConnection();
-            var linkCache = new Dictionary<int, TrackLinkDTO>();
-            foreach (var member in indexMembers)
-            {
-                var parts = member.Split('|');
-                if (parts.Length < 2 || parts.Length > 4) continue;
-
-                string eventType = NormalizeEventType(parts[0]);
-                if (!SupportEventTypes.Contains(eventType)) continue;
-                if (!int.TryParse(parts[1], out var trackId) || trackId <= 0) continue;
-                bool hasScenePart = parts.Length >= 3;
-                string memberScene = hasScenePart ? DecodeIndexPart(parts[2]) : string.Empty;
-                int accountId = 0;
-                if (parts.Length == 4 && (!int.TryParse(parts[3], out accountId) || accountId <= 0)) continue;
-
-                string countKey = BuildDailyCountKey(eventType, trackId, dateStr, hasScenePart ? memberScene : string.Empty, accountId);
-                int total = await RedisHelper.GetAsync<int>(countKey);
-                if (total <= 0) continue;
-
-                if (!linkCache.TryGetValue(trackId, out var link))
-                {
-                    link = new DBContext.Table(conn, "track_links")
-                        .Get<TrackLinkDTO>("id=@id", new { id = trackId });
-                    if (link != null) linkCache[trackId] = link;
-                }
-
-                if (link != null)
-                {
-                    string linkEventType = NormalizeEventType(link.event_type);
-                    if (!SupportEventTypes.Contains(linkEventType)) continue;
-                    if (!string.Equals(eventType, linkEventType, StringComparison.OrdinalIgnoreCase))
-                    {
-                        _ = new LoggerLibrary("TracksCore", "FlushDaily")
-                            .Info($"skip stale track bucket: member={member}, link_event_type={linkEventType}", string.Empty)
-                            .SaveAsync();
-                        continue;
-                    }
-                    eventType = linkEventType;
-                }
-
-                string reportScene = ResolveMetricScene(link, memberScene);
-                string bucketKey = BuildMetricIndexValue(eventType, trackId, reportScene, accountId);
-                if (!buckets.TryGetValue(bucketKey, out var bucket))
-                {
-                    bucket = new DailyReportBucket
-                    {
-                        EventType = eventType,
-                        TrackId = trackId,
-                        Scene = reportScene,
-                        AccountId = accountId,
-                        Link = link
-                    };
-                    buckets[bucketKey] = bucket;
-                }
-                bucket.Total += total;
-            }
-
-            int rows = 0;
-            foreach (var bucket in buckets.Values)
-            {
-                await UpsertDailyReportAsync(conn, targetDate.Date, bucket.EventType, bucket.TrackId, bucket.Scene, bucket.AccountId, bucket.Link, bucket.Total);
-                rows++;
-            }
-            return rows;
-        }
-
-        private static Task<int> UpsertDailyReportAsync(IDbConnection conn, DateTime reportDate, string eventType, int trackId, string scene, int accountId, TrackLinkDTO? link, int total)
-        {
-            const string sql = @"
-INSERT INTO track_daily_report
-    (report_date, event_type, track_link_id, account_id, platform, typename, scene, unique_id, event_count, create_time, update_time)
-VALUES
-    (@reportDate, @eventType, @trackId, @accountId, @platform, @typename, @scene, @uniqueId, @eventCount, @now, @now)
-ON DUPLICATE KEY UPDATE
-    event_count = VALUES(event_count),
-    account_id = VALUES(account_id),
-    platform = VALUES(platform),
-    typename = VALUES(typename),
-    scene = VALUES(scene),
-    unique_id = VALUES(unique_id),
-    update_time = VALUES(update_time);";
-
-            return conn.ExecuteAsync(sql, new
-            {
-                reportDate,
-                eventType,
-                trackId,
-                accountId,
-                platform = link?.platform ?? string.Empty,
-                typename = link?.typename ?? string.Empty,
-                scene,
-                uniqueId = link?.unique_id ?? string.Empty,
-                eventCount = total,
-                now = DateTime.Now
-            });
-        }
-
-        public static async Task<List<TrackHourlyReportDTO>> GetHourlyReportAsync(int trackId, DateTime targetDate, string scene = "", int accountId = 0)
-        {
-            var result = new List<TrackHourlyReportDTO>();
-            if (trackId <= 0) return result;
-
-            var link = await GetLinkByIdAsync(trackId);
-            if (link == null) return result;
-
-            string eventType = NormalizeEventType(link.event_type);
-            if (!SupportEventTypes.Contains(eventType)) return result;
-
-            string metricScene = ResolveMetricScene(link, scene);
-            string linkScene = ResolveMetricScene(link, string.Empty);
-            string dateStr = targetDate.ToString("yyyyMMdd");
-            accountId = Math.Max(accountId, 0);
-            for (int hour = 0; hour < 24; hour++)
-            {
-                string hourStr = $"{dateStr}{hour:00}";
-                int total = await RedisHelper.GetAsync<int>(BuildHourlyCountKey(eventType, trackId, hourStr, metricScene, accountId));
-                if (accountId == 0 && !string.IsNullOrEmpty(metricScene) && metricScene == linkScene)
-                {
-                    total += await RedisHelper.GetAsync<int>(BuildHourlyCountKey(eventType, trackId, hourStr, string.Empty));
-                }
-
-                result.Add(new TrackHourlyReportDTO
-                {
-                    track_link_id = trackId,
-                    event_type = eventType,
-                    platform = link.platform ?? string.Empty,
-                    typename = link.typename ?? string.Empty,
-                    scene = metricScene,
-                    unique_id = link.unique_id ?? string.Empty,
-                    account_id = accountId,
-                    report_date = targetDate.Date,
-                    hour = hour,
-                    event_count = total
-                });
-            }
-
-            return result;
-        }
-
-        public static string BuildPath(TrackType type, TkDataDTO result)
-        {
-            int trackId = type == TrackType.Click ? 1 : 2;
-            if (result == null) return string.Empty;
-            string unique_id = $"1|{result.itemId}_{result.mktId}";
-            return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
-        }
-        public static string BuildPath(TrackType type, JdDataDTO result)
-        {
-            int trackId = type == TrackType.Click ? 19 : 20;
-            if (result == null) return string.Empty;
-            string unique_id = $"13|{result.shortLinkurl.UrlEncode()}";
-            return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
-        }
-        public static string BuildPath(TrackType type, PddDataDTO result)
-        {
-            int trackId = type == TrackType.Click ? 21 : 22;
-            if (result == null) return string.Empty;
-            string unique_id = $"9|{result.shortLinkurl.UrlEncode()}";
-            return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
-        }
-
-        public static string BuildBrwSimilarPath(TrackType type, TkDataDTO result, PromotionQueryItemDTO similar_goods = null, int index = 0)
-        {
-            int trackId = type == TrackType.Click ? 23 : 24;
-            if (result == null) return string.Empty;
-            string unique_id = $"1|{result.itemId}_{result.mktId}";
-            string scene = string.Empty;
-
-            if (similar_goods != null)
-            {
-                unique_id = $"1|{result.itemId}_{result.mktId}_{index}";
-                scene = "similar";
-            }
-
-            return BuildPath(trackId, unique_id, scene, result.accountId);
-        }
-        public static string BuildPath(int trackId, string unique_id = "", string scene = "", int accountId = 0)
-        {
-            scene = NormalizeReportScene(scene);
-            string url = $"https://api.molilian.com/tracks/track?track_id={trackId}&unique_id={unique_id}";
-            if (!string.IsNullOrEmpty(scene)) url += $"&scene={scene.UrlEncode()}";
-            if (accountId > 0) url += $"&account_id={accountId}";
-            return url;
-        }
-
-        public static string ResolveMetricScene(TrackLinkDTO? link, string scene = "")
-        {
-            scene = NormalizeReportScene(scene);
-            if (!string.IsNullOrEmpty(scene)) return scene;
-            return NormalizeOrAll(link?.scene ?? string.Empty);
-        }
-
-        public static string GetLinkCacheKey(string eventType, string typename, string scene, string uniqueId)
-        {
-            return GetLinkCacheKey(eventType, string.Empty, typename, scene, uniqueId);
-        }
-
-        public static string GetLinkCacheKey(string eventType, string platform, string typename, string scene, string uniqueId)
-        {
-            eventType = NormalizeEventType(eventType);
-            platform = Normalize(platform);
-            typename = Normalize(typename);
-            scene = NormalizeOrAll(scene);
-            uniqueId = NormalizeOrAll(uniqueId);
-            return $"{RedisPrefix}:cache:{LinkCacheVersion}:link:{eventType}:{platform}:{typename}:{scene}:{uniqueId}";
-        }
-
-        public static string GetLinkIdCacheKey(int trackId)
-        {
-            return $"{RedisPrefix}:cache:{LinkCacheVersion}:linkid:{trackId}";
-        }
-
-        public static async Task<TrackLinkDTO> GetLinkByIdAsync(int trackId)
-        {
-            if (trackId <= 0) return null;
-            string cacheKey = GetLinkIdCacheKey(trackId);
-            try
-            {
-                var cached = RedisHelper.Get<TrackLinkDTO>(cacheKey);
-                if (cached != null) return cached;
-            }
-            catch { }
-
-            try
-            {
-                var item = new DBContext.Table("track_links").Get<TrackLinkDTO>("id=@id", new { id = trackId });
-                if (item != null)
-                {
-                    _ = RedisHelper.Set(cacheKey, item, LinkCacheExpireSeconds);
-                }
-                return item;
-            }
-            catch
-            {
-                return null;
-            }
-        }
-
-        public static Task<bool> LogTrackRequestAsync(TrackRequestLogDTO dto)
-        {
-            try
-            {
-                dto.event_type = NormalizeEventType(dto.event_type);
-                dto.platform = Normalize(dto.platform);
-                dto.typename = Normalize(dto.typename);
-                dto.scene = NormalizeOrAll(dto.scene);
-                dto.unique_id = NormalizeOrAll(dto.unique_id);
-                dto.ip = Normalize(dto.ip);
-                dto.user_agent = Normalize(dto.user_agent);
-                dto.referer = Normalize(dto.referer);
-                dto.create_time = DateTime.Now;
-
-                RedisHelper.RPush(TrackRequestLogKey, dto);
-                return Task.FromResult(true);
-            }
-            catch
-            {
-                return Task.FromResult(false);
-            }
-        }
-
-        public static async Task<int> InsertTrackRequestLogAsync(int limit, YunhuiKit.RedisClient redis)
-        {
-            int count = 0;
-            try
-            {
-                using var conn = DBContext.GetOpenConnection();
-                for (int i = 0; i < limit; i++)
-                {
-                    var entity = await redis.LPopAsync<TrackRequestLogDTO>(TrackRequestLogKey);
-                    if (entity == null) break;
-                    try
-                    {
-                        if (entity.create_time == default) entity.create_time = DateTime.Now;
-                        conn.Insert(entity);
-                        count++;
-                    }
-                    catch
-                    {
-                        // ignore malformed item
-                    }
-                }
-            }
-            catch
-            {
-                return count;
-            }
-            return count;
-        }
-
-        private static string Normalize(string value)
-        {
-            return (value ?? string.Empty).Trim();
-        }
-
-        private static string NormalizeReportScene(string value)
-        {
-            var scene = Normalize(value);
-            return scene == "默认场景" ? string.Empty : scene;
-        }
-
-        private static string NormalizeOrAll(string value)
-        {
-            var result = Normalize(value);
-            return string.IsNullOrEmpty(result) ? DefaultDimensionValue : result;
-        }
-
-        private static string NormalizeEventType(string value)
-        {
-            return Normalize(value).ToLowerInvariant();
-        }
-
-        private static string GetTrackScene(string parseType, string riskStrategy)
-        {
-            parseType = NormalizeReportScene(parseType);
-            if (!string.IsNullOrEmpty(parseType)) return parseType;
-
-            riskStrategy = NormalizeReportScene(riskStrategy);
-            if (!string.IsNullOrEmpty(riskStrategy)) return riskStrategy;
-
-            return DefaultDimensionValue;
-        }
-
-        private static string BuildDailyCountKey(string eventType, int trackId, string dateStr, string scene, int accountId = 0)
-        {
-            eventType = NormalizeEventType(eventType);
-            scene = NormalizeReportScene(scene);
-            string key = $"{RedisPrefix}:daily:{eventType}:{trackId}:{dateStr}";
-            if (!string.IsNullOrEmpty(scene)) key += $":{EncodeIndexPart(scene)}";
-            if (accountId > 0) key += $":account:{accountId}";
-            return key;
-        }
-
-        private static string BuildHourlyCountKey(string eventType, int trackId, string hourStr, string scene, int accountId = 0)
-        {
-            eventType = NormalizeEventType(eventType);
-            scene = NormalizeReportScene(scene);
-            string key = $"{RedisPrefix}:hour:{eventType}:{trackId}:{hourStr}";
-            if (!string.IsNullOrEmpty(scene)) key += $":{EncodeIndexPart(scene)}";
-            if (accountId > 0) key += $":account:{accountId}";
-            return key;
-        }
-
-        private static string BuildMetricIndexValue(string eventType, int trackId, string scene, int accountId = 0)
-        {
-            eventType = NormalizeEventType(eventType);
-            scene = NormalizeReportScene(scene);
-            if (accountId > 0) return $"{eventType}|{trackId}|{EncodeIndexPart(scene)}|{accountId}";
-            if (string.IsNullOrEmpty(scene)) return $"{eventType}|{trackId}";
-            return $"{eventType}|{trackId}|{EncodeIndexPart(scene)}";
-        }
-
-        private static string EncodeIndexPart(string value)
-        {
-            return Normalize(value).UrlEncode();
-        }
-
-        private static string DecodeIndexPart(string value)
-        {
-            try
-            {
-                return Normalize(value).UrlDecode();
-            }
-            catch
-            {
-                return Normalize(value);
-            }
-        }
-
-        private sealed class DailyReportBucket
-        {
-            public string EventType { get; set; } = string.Empty;
-            public int TrackId { get; set; }
-            public string Scene { get; set; } = string.Empty;
-            public int AccountId { get; set; }
-            public TrackLinkDTO? Link { get; set; }
-            public int Total { get; set; }
-        }
-
-        private static int GetTrackLinkId(IDbConnection conn, string eventType, string platform, string scene, string uniqueId)
-        {
-            try
-            {
-                var record = new DBContext.Table(conn, "track_links")
-                    .Fields("id")
-                    .Get<TrackLinkDTO>("event_type=@event_type AND platform=@platform AND scene=@scene AND unique_id=@unique_id",
-                        new { event_type = eventType, platform, scene, unique_id = uniqueId });
+            string dateStr = targetDate.ToString("yyyyMMdd");
+            string indexKey = $"{RedisPrefix}:daily:index:{dateStr}:track";
+
+            var indexMembers = await RedisHelper.SMembersAsync<string>(indexKey) ?? [];
+            if (indexMembers == null || indexMembers.Length == 0) return 0;
+
+            var buckets = new Dictionary<string, DailyReportBucket>();
+            using var conn = DBContext.GetOpenConnection();
+            var linkCache = new Dictionary<int, TrackLinkDTO>();
+            foreach (var member in indexMembers)
+            {
+                var parts = member.Split('|');
+                if (parts.Length < 2 || parts.Length > 4) continue;
+
+                string eventType = NormalizeEventType(parts[0]);
+                if (!SupportEventTypes.Contains(eventType)) continue;
+                if (!int.TryParse(parts[1], out var trackId) || trackId <= 0) continue;
+                bool hasScenePart = parts.Length >= 3;
+                string memberScene = hasScenePart ? DecodeIndexPart(parts[2]) : string.Empty;
+                int accountId = 0;
+                if (parts.Length == 4 && (!int.TryParse(parts[3], out accountId) || accountId <= 0)) continue;
+
+                string countKey = BuildDailyCountKey(eventType, trackId, dateStr, hasScenePart ? memberScene : string.Empty, accountId);
+                int total = await RedisHelper.GetAsync<int>(countKey);
+                if (total <= 0) continue;
+
+                if (!linkCache.TryGetValue(trackId, out var link))
+                {
+                    link = new DBContext.Table(conn, "track_links")
+                        .Get<TrackLinkDTO>("id=@id", new { id = trackId });
+                    if (link != null) linkCache[trackId] = link;
+                }
+
+                if (link != null)
+                {
+                    string linkEventType = NormalizeEventType(link.event_type);
+                    if (!SupportEventTypes.Contains(linkEventType)) continue;
+                    if (!string.Equals(eventType, linkEventType, StringComparison.OrdinalIgnoreCase))
+                    {
+                        _ = new LoggerLibrary("TracksCore", "FlushDaily")
+                            .Info($"skip stale track bucket: member={member}, link_event_type={linkEventType}", string.Empty)
+                            .SaveAsync();
+                        continue;
+                    }
+                    eventType = linkEventType;
+                }
+
+                string reportScene = ResolveMetricScene(link, memberScene);
+                string bucketKey = BuildMetricIndexValue(eventType, trackId, reportScene, accountId);
+                if (!buckets.TryGetValue(bucketKey, out var bucket))
+                {
+                    bucket = new DailyReportBucket
+                    {
+                        EventType = eventType,
+                        TrackId = trackId,
+                        Scene = reportScene,
+                        AccountId = accountId,
+                        Link = link
+                    };
+                    buckets[bucketKey] = bucket;
+                }
+                bucket.Total += total;
+            }
+
+            int rows = 0;
+            foreach (var bucket in buckets.Values)
+            {
+                await UpsertDailyReportAsync(conn, targetDate.Date, bucket.EventType, bucket.TrackId, bucket.Scene, bucket.AccountId, bucket.Link, bucket.Total);
+                rows++;
+            }
+            return rows;
+        }
+
+        private static Task<int> UpsertDailyReportAsync(IDbConnection conn, DateTime reportDate, string eventType, int trackId, string scene, int accountId, TrackLinkDTO? link, int total)
+        {
+            const string sql = @"
+INSERT INTO track_daily_report
+    (report_date, event_type, track_link_id, account_id, platform, typename, scene, unique_id, event_count, create_time, update_time)
+VALUES
+    (@reportDate, @eventType, @trackId, @accountId, @platform, @typename, @scene, @uniqueId, @eventCount, @now, @now)
+ON DUPLICATE KEY UPDATE
+    event_count = VALUES(event_count),
+    account_id = VALUES(account_id),
+    platform = VALUES(platform),
+    typename = VALUES(typename),
+    scene = VALUES(scene),
+    unique_id = VALUES(unique_id),
+    update_time = VALUES(update_time);";
+
+            return conn.ExecuteAsync(sql, new
+            {
+                reportDate,
+                eventType,
+                trackId,
+                accountId,
+                platform = link?.platform ?? string.Empty,
+                typename = link?.typename ?? string.Empty,
+                scene,
+                uniqueId = link?.unique_id ?? string.Empty,
+                eventCount = total,
+                now = DateTime.Now
+            });
+        }
+
+        public static async Task<List<TrackHourlyReportDTO>> GetHourlyReportAsync(int trackId, DateTime targetDate, string scene = "", int accountId = 0)
+        {
+            var result = new List<TrackHourlyReportDTO>();
+            if (trackId <= 0) return result;
+
+            var link = await GetLinkByIdAsync(trackId);
+            if (link == null) return result;
+
+            string eventType = NormalizeEventType(link.event_type);
+            if (!SupportEventTypes.Contains(eventType)) return result;
+
+            string metricScene = ResolveMetricScene(link, scene);
+            string linkScene = ResolveMetricScene(link, string.Empty);
+            string dateStr = targetDate.ToString("yyyyMMdd");
+            accountId = Math.Max(accountId, 0);
+            for (int hour = 0; hour < 24; hour++)
+            {
+                string hourStr = $"{dateStr}{hour:00}";
+                int total = await RedisHelper.GetAsync<int>(BuildHourlyCountKey(eventType, trackId, hourStr, metricScene, accountId));
+                if (accountId == 0 && !string.IsNullOrEmpty(metricScene) && metricScene == linkScene)
+                {
+                    total += await RedisHelper.GetAsync<int>(BuildHourlyCountKey(eventType, trackId, hourStr, string.Empty));
+                }
+                int callCount = await GetTrackHourlyCallCountAsync(link, hourStr, accountId);
+
+                result.Add(new TrackHourlyReportDTO
+                {
+                    track_link_id = trackId,
+                    event_type = eventType,
+                    platform = link.platform ?? string.Empty,
+                    typename = link.typename ?? string.Empty,
+                    scene = metricScene,
+                    unique_id = link.unique_id ?? string.Empty,
+                    account_id = accountId,
+                    report_date = targetDate.Date,
+                    hour = hour,
+                    event_count = total,
+                    call_count = callCount
+                });
+            }
+
+            return result;
+        }
+
+        private static async Task<int> GetTrackHourlyCallCountAsync(TrackLinkDTO link, string hourStr, int accountId)
+        {
+            if (accountId <= 0) return 0;
+
+            string channelName = GetTrackCallChannelName(link);
+            if (string.IsNullOrEmpty(channelName)) return 0;
+
+            return await TkLogCore.GetTotalAsync($":parse_total:{channelName}_{accountId}:{hourStr}");
+        }
+
+        private static string GetTrackCallChannelName(TrackLinkDTO link)
+        {
+            string platform = Normalize(link.platform).ToLowerInvariant();
+
+            if (link.id is 19 or 20 || platform == "jd" || platform == "13" || platform.Contains("京东"))
+            {
+                return "jd";
+            }
+
+            if (link.id is 21 or 22 || platform == "pdd" || platform == "9" || platform.Contains("拼多多"))
+            {
+                return "pdd";
+            }
+
+            return "tb";
+        }
+
+        public static string BuildPath(TrackType type, TkDataDTO result)
+        {
+            int trackId = type == TrackType.Click ? 1 : 2;
+            if (result == null) return string.Empty;
+            string unique_id = $"1|{result.itemId}_{result.mktId}";
+            return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
+        }
+        public static string BuildPath(TrackType type, JdDataDTO result)
+        {
+            int trackId = type == TrackType.Click ? 19 : 20;
+            if (result == null) return string.Empty;
+            string unique_id = $"13|{result.shortLinkurl.UrlEncode()}";
+            return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
+        }
+        public static string BuildPath(TrackType type, PddDataDTO result)
+        {
+            int trackId = type == TrackType.Click ? 21 : 22;
+            if (result == null) return string.Empty;
+            string unique_id = $"9|{result.shortLinkurl.UrlEncode()}";
+            return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
+        }
+
+        public static string BuildBrwSimilarPath(TrackType type, TkDataDTO result, PromotionQueryItemDTO similar_goods = null, int index = 0)
+        {
+            int trackId = type == TrackType.Click ? 23 : 24;
+            if (result == null) return string.Empty;
+            string unique_id = $"1|{result.itemId}_{result.mktId}";
+            string scene = string.Empty;
+
+            if (similar_goods != null)
+            {
+                unique_id = $"1|{result.itemId}_{result.mktId}_{index}";
+                scene = "similar";
+            }
+
+            return BuildPath(trackId, unique_id, scene, result.accountId);
+        }
+        public static string BuildPath(int trackId, string unique_id = "", string scene = "", int accountId = 0)
+        {
+            scene = NormalizeReportScene(scene);
+            string url = $"https://api.molilian.com/tracks/track?track_id={trackId}&unique_id={unique_id}";
+            if (!string.IsNullOrEmpty(scene)) url += $"&scene={scene.UrlEncode()}";
+            if (accountId > 0) url += $"&account_id={accountId}";
+            return url;
+        }
+
+        public static string ResolveMetricScene(TrackLinkDTO? link, string scene = "")
+        {
+            scene = NormalizeReportScene(scene);
+            if (!string.IsNullOrEmpty(scene)) return scene;
+            return NormalizeOrAll(link?.scene ?? string.Empty);
+        }
+
+        public static string GetLinkCacheKey(string eventType, string typename, string scene, string uniqueId)
+        {
+            return GetLinkCacheKey(eventType, string.Empty, typename, scene, uniqueId);
+        }
+
+        public static string GetLinkCacheKey(string eventType, string platform, string typename, string scene, string uniqueId)
+        {
+            eventType = NormalizeEventType(eventType);
+            platform = Normalize(platform);
+            typename = Normalize(typename);
+            scene = NormalizeOrAll(scene);
+            uniqueId = NormalizeOrAll(uniqueId);
+            return $"{RedisPrefix}:cache:{LinkCacheVersion}:link:{eventType}:{platform}:{typename}:{scene}:{uniqueId}";
+        }
+
+        public static string GetLinkIdCacheKey(int trackId)
+        {
+            return $"{RedisPrefix}:cache:{LinkCacheVersion}:linkid:{trackId}";
+        }
+
+        public static async Task<TrackLinkDTO> GetLinkByIdAsync(int trackId)
+        {
+            if (trackId <= 0) return null;
+            string cacheKey = GetLinkIdCacheKey(trackId);
+            try
+            {
+                var cached = RedisHelper.Get<TrackLinkDTO>(cacheKey);
+                if (cached != null) return cached;
+            }
+            catch { }
+
+            try
+            {
+                var item = new DBContext.Table("track_links").Get<TrackLinkDTO>("id=@id", new { id = trackId });
+                if (item != null)
+                {
+                    _ = RedisHelper.Set(cacheKey, item, LinkCacheExpireSeconds);
+                }
+                return item;
+            }
+            catch
+            {
+                return null;
+            }
+        }
+
+        public static Task<bool> LogTrackRequestAsync(TrackRequestLogDTO dto)
+        {
+            try
+            {
+                dto.event_type = NormalizeEventType(dto.event_type);
+                dto.platform = Normalize(dto.platform);
+                dto.typename = Normalize(dto.typename);
+                dto.scene = NormalizeOrAll(dto.scene);
+                dto.unique_id = NormalizeOrAll(dto.unique_id);
+                dto.ip = Normalize(dto.ip);
+                dto.user_agent = Normalize(dto.user_agent);
+                dto.referer = Normalize(dto.referer);
+                dto.create_time = DateTime.Now;
+
+                RedisHelper.RPush(TrackRequestLogKey, dto);
+                return Task.FromResult(true);
+            }
+            catch
+            {
+                return Task.FromResult(false);
+            }
+        }
+
+        public static async Task<int> InsertTrackRequestLogAsync(int limit, YunhuiKit.RedisClient redis)
+        {
+            int count = 0;
+            try
+            {
+                using var conn = DBContext.GetOpenConnection();
+                for (int i = 0; i < limit; i++)
+                {
+                    var entity = await redis.LPopAsync<TrackRequestLogDTO>(TrackRequestLogKey);
+                    if (entity == null) break;
+                    try
+                    {
+                        if (entity.create_time == default) entity.create_time = DateTime.Now;
+                        conn.Insert(entity);
+                        count++;
+                    }
+                    catch
+                    {
+                        // ignore malformed item
+                    }
+                }
+            }
+            catch
+            {
+                return count;
+            }
+            return count;
+        }
+
+        private static string Normalize(string value)
+        {
+            return (value ?? string.Empty).Trim();
+        }
+
+        private static string NormalizeReportScene(string value)
+        {
+            var scene = Normalize(value);
+            return scene == "默认场景" ? string.Empty : scene;
+        }
+
+        private static string NormalizeOrAll(string value)
+        {
+            var result = Normalize(value);
+            return string.IsNullOrEmpty(result) ? DefaultDimensionValue : result;
+        }
+
+        private static string NormalizeEventType(string value)
+        {
+            return Normalize(value).ToLowerInvariant();
+        }
+
+        private static string GetTrackScene(string parseType, string riskStrategy)
+        {
+            parseType = NormalizeReportScene(parseType);
+            if (!string.IsNullOrEmpty(parseType)) return parseType;
+
+            riskStrategy = NormalizeReportScene(riskStrategy);
+            if (!string.IsNullOrEmpty(riskStrategy)) return riskStrategy;
+
+            return DefaultDimensionValue;
+        }
+
+        private static string BuildDailyCountKey(string eventType, int trackId, string dateStr, string scene, int accountId = 0)
+        {
+            eventType = NormalizeEventType(eventType);
+            scene = NormalizeReportScene(scene);
+            string key = $"{RedisPrefix}:daily:{eventType}:{trackId}:{dateStr}";
+            if (!string.IsNullOrEmpty(scene)) key += $":{EncodeIndexPart(scene)}";
+            if (accountId > 0) key += $":account:{accountId}";
+            return key;
+        }
+
+        private static string BuildHourlyCountKey(string eventType, int trackId, string hourStr, string scene, int accountId = 0)
+        {
+            eventType = NormalizeEventType(eventType);
+            scene = NormalizeReportScene(scene);
+            string key = $"{RedisPrefix}:hour:{eventType}:{trackId}:{hourStr}";
+            if (!string.IsNullOrEmpty(scene)) key += $":{EncodeIndexPart(scene)}";
+            if (accountId > 0) key += $":account:{accountId}";
+            return key;
+        }
+
+        private static string BuildMetricIndexValue(string eventType, int trackId, string scene, int accountId = 0)
+        {
+            eventType = NormalizeEventType(eventType);
+            scene = NormalizeReportScene(scene);
+            if (accountId > 0) return $"{eventType}|{trackId}|{EncodeIndexPart(scene)}|{accountId}";
+            if (string.IsNullOrEmpty(scene)) return $"{eventType}|{trackId}";
+            return $"{eventType}|{trackId}|{EncodeIndexPart(scene)}";
+        }
+
+        private static string EncodeIndexPart(string value)
+        {
+            return Normalize(value).UrlEncode();
+        }
+
+        private static string DecodeIndexPart(string value)
+        {
+            try
+            {
+                return Normalize(value).UrlDecode();
+            }
+            catch
+            {
+                return Normalize(value);
+            }
+        }
+
+        private sealed class DailyReportBucket
+        {
+            public string EventType { get; set; } = string.Empty;
+            public int TrackId { get; set; }
+            public string Scene { get; set; } = string.Empty;
+            public int AccountId { get; set; }
+            public TrackLinkDTO? Link { get; set; }
+            public int Total { get; set; }
+        }
+
+        private static int GetTrackLinkId(IDbConnection conn, string eventType, string platform, string scene, string uniqueId)
+        {
+            try
+            {
+                var record = new DBContext.Table(conn, "track_links")
+                    .Fields("id")
+                    .Get<TrackLinkDTO>("event_type=@event_type AND platform=@platform AND scene=@scene AND unique_id=@unique_id",
+                        new { event_type = eventType, platform, scene, unique_id = uniqueId });
                 if (record == null) return 0;
-                return record.id;
-            }
-            catch
-            {
-                return 0;
-            }
+                return record.id;
+            }
+            catch
+            {
+                return 0;
+            }
         }
     }
-
-}
+
+}

+ 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),

+ 1 - 0
molilian.core/DTO/TracksDTO.cs

@@ -50,6 +50,7 @@ namespace molilian.core
         public int hour { get; set; } = 0;
         public string hour_label => $"{hour:00}:00";
         public int event_count { get; set; } = 0;
+        public int call_count { get; set; } = 0;
     }
 
     [Table("track_request_logs")]

+ 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;
+        }
+    }
+}

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff