Ver código fonte

✨ feat(报告): 增加请求量字段并优化登录时间显示

dodo hold 1 mês atrás
pai
commit
b2e9cd8710

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

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
molilian.api/Properties/PublishProfiles/latest.pubxml.user


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

+ 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")]

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff