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 { 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 TrackRequestLogKey = ":tracks:request:logs"; private static readonly HashSet SupportEventTypes = new(StringComparer.OrdinalIgnoreCase) { "expose", "click" }; /// /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links /// public static Task CreateLinkAsync(string eventType, string typename, string scene, string uniqueId, string description = "", bool forceNew = false) { return CreateLinkAsync(eventType, string.Empty, typename, scene, uniqueId, description, forceNew); } /// /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links /// public static Task 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(null); } string cacheKey = GetLinkCacheKey(eventType, platform, typename, scene, uniqueId); if (!forceNew) { try { int cachedId = RedisHelper.Get(cacheKey); if (cachedId > 0) { var cachedLink = RedisHelper.Get(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("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(null); } } /// /// 曝光/点击触发:计入 Redis,失败不影响返回 /// public static Task TrackAsync(TrackLinkDTO link) { string dateStr = DateTime.Now.ToString("yyyyMMdd"); string hourStr = DateTime.Now.ToString("yyyyMMddHH"); var metrics = new List<(string key, string indexKey, string indexValue, int expire)> { ($"{RedisPrefix}:daily:{link.event_type}:{link.id}:{dateStr}", $"{RedisPrefix}:daily:index:{dateStr}:track", $"{link.event_type}|{link.id}", DailyExpireSeconds), ($"{RedisPrefix}:hour:{link.event_type}:{link.id}:{hourStr}", $"{RedisPrefix}:hour:index:{hourStr}:track", $"{link.event_type}|{link.id}", 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); } } /// /// 日报:拉取指定日期 Redis 计数并落地到 track_daily_report,可高频重复执行。 /// public static async Task FlushDailyAsync(DateTime targetDate) { string dateStr = targetDate.ToString("yyyyMMdd"); string indexKey = $"{RedisPrefix}:daily:index:{dateStr}:track"; var indexMembers = await RedisHelper.SMembersAsync(indexKey) ?? []; if (indexMembers == null || indexMembers.Length == 0) return 0; int rows = 0; using var conn = DBContext.GetOpenConnection(); var linkCache = new Dictionary(); foreach (var member in indexMembers) { var parts = member.Split('|'); if (parts.Length != 2) continue; string eventType = NormalizeEventType(parts[0]); if (!SupportEventTypes.Contains(eventType)) continue; if (!int.TryParse(parts[1], out var trackId) || trackId <= 0) continue; string countKey = $"{RedisPrefix}:daily:{eventType}:{trackId}:{dateStr}"; int total = await RedisHelper.GetAsync(countKey); if (total <= 0) continue; if (!linkCache.TryGetValue(trackId, out var link)) { link = new DBContext.Table(conn, "track_links") .Get("id=@id", new { id = trackId }); if (link != null) linkCache[trackId] = link; } await UpsertDailyReportAsync(conn, targetDate.Date, eventType, trackId, link, total); rows++; } return rows; } private static Task UpsertDailyReportAsync(IDbConnection conn, DateTime reportDate, string eventType, int trackId, TrackLinkDTO link, int total) { const string sql = @" INSERT INTO track_daily_report (report_date, event_type, track_link_id, platform, typename, scene, unique_id, event_count, create_time, update_time) VALUES (@reportDate, @eventType, @trackId, @platform, @typename, @scene, @uniqueId, @eventCount, @now, @now) ON DUPLICATE KEY UPDATE event_count = VALUES(event_count), 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, platform = link?.platform ?? string.Empty, typename = link?.typename ?? string.Empty, scene = link?.scene ?? string.Empty, uniqueId = link?.unique_id ?? string.Empty, eventCount = total, now = DateTime.Now }); } public static async Task> GetHourlyReportAsync(int trackId, DateTime targetDate) { var result = new List(); 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 dateStr = targetDate.ToString("yyyyMMdd"); for (int hour = 0; hour < 24; hour++) { string hourStr = $"{dateStr}{hour:00}"; string countKey = $"{RedisPrefix}:hour:{eventType}:{trackId}:{hourStr}"; int total = await RedisHelper.GetAsync(countKey); result.Add(new TrackHourlyReportDTO { track_link_id = trackId, event_type = eventType, platform = link.platform ?? string.Empty, typename = link.typename ?? string.Empty, scene = link.scene ?? string.Empty, unique_id = link.unique_id ?? string.Empty, 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); } 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); } 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); } public static string BuildPath(int trackId, string unique_id = "") { return $"https://api.molilian.com/tracks/track?track_id={trackId}&unique_id={unique_id}"; } 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}:link:{eventType}:{platform}:{typename}:{scene}:{uniqueId}"; } public static string GetLinkIdCacheKey(int trackId) { return $"{RedisPrefix}:linkid:{trackId}"; } public static async Task GetLinkByIdAsync(int trackId) { if (trackId <= 0) return null; string cacheKey = GetLinkIdCacheKey(trackId); try { var cached = RedisHelper.Get(cacheKey); if (cached != null) return cached; } catch { } try { var item = new DBContext.Table("track_links").Get("id=@id", new { id = trackId }); if (item != null) { _ = RedisHelper.Set(cacheKey, item, LinkCacheExpireSeconds); } return item; } catch { return null; } } public static Task 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 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(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 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 int GetTrackLinkId(IDbConnection conn, string eventType, string platform, string scene, string uniqueId) { try { var record = new DBContext.Table(conn, "track_links") .Fields("id") .Get("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; } } } }