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 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); } } /// /// 日报:拉取指定日期 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; var buckets = new Dictionary(); using var conn = DBContext.GetOpenConnection(); var linkCache = new Dictionary(); 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(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; } 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 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> GetHourlyReportAsync(int trackId, DateTime targetDate, string scene = "", int accountId = 0) { 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 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(BuildHourlyCountKey(eventType, trackId, hourStr, metricScene, accountId)); if (accountId == 0 && !string.IsNullOrEmpty(metricScene) && metricScene == linkScene) { total += await RedisHelper.GetAsync(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}: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 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("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; } } } }