TracksCore.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Data;
  4. using System.Net;
  5. using System.Threading.Tasks;
  6. using Dapper;
  7. using CSRedis;
  8. using dodohold.core;
  9. using System.Text.Json;
  10. using YunhuiKit;
  11. namespace molilian.core
  12. {
  13. public enum TrackType
  14. {
  15. Expose,
  16. Click
  17. }
  18. public partial class TracksCore
  19. {
  20. private const string RedisPrefix = ":tracks_v123";
  21. private const int DailyExpireSeconds = 40 * 86400;
  22. private const int HourlyExpireSeconds = 7 * 86400; // keep a week of hourly buckets
  23. private const string DefaultDimensionValue = "";
  24. private const int LinkCacheExpireSeconds = 30 * 86400;
  25. private const string TrackRequestLogKey = ":tracks:request:logs";
  26. private static readonly HashSet<string> SupportEventTypes = new(StringComparer.OrdinalIgnoreCase) { "expose", "click" };
  27. /// <summary>
  28. /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links
  29. /// </summary>
  30. public static Task<TrackLinkDTO> CreateLinkAsync(string eventType, string typename, string scene, string uniqueId, string description = "", bool forceNew = false)
  31. {
  32. return CreateLinkAsync(eventType, string.Empty, typename, scene, uniqueId, description, forceNew);
  33. }
  34. /// <summary>
  35. /// 生成一个监测链接(仅返回 path,不包含域名),同时落地到 track_links
  36. /// </summary>
  37. public static Task<TrackLinkDTO> CreateLinkAsync(string eventType, string platform, string typename, string scene, string uniqueId, string description = "", bool forceNew = false)
  38. {
  39. eventType = NormalizeEventType(eventType);
  40. platform = Normalize(platform);
  41. typename = Normalize(typename);
  42. scene = NormalizeOrAll(scene);
  43. uniqueId = NormalizeOrAll(uniqueId);
  44. description = Normalize(description);
  45. if (!SupportEventTypes.Contains(eventType))
  46. {
  47. return Task.FromResult<TrackLinkDTO>(null);
  48. }
  49. string cacheKey = GetLinkCacheKey(eventType, platform, typename, scene, uniqueId);
  50. if (!forceNew)
  51. {
  52. try
  53. {
  54. int cachedId = RedisHelper.Get<int>(cacheKey);
  55. if (cachedId > 0)
  56. {
  57. var cachedLink = RedisHelper.Get<TrackLinkDTO>(GetLinkIdCacheKey(cachedId));
  58. if (cachedLink != null) return Task.FromResult(cachedLink);
  59. }
  60. }
  61. catch
  62. {
  63. // ignore cache errors
  64. }
  65. }
  66. try
  67. {
  68. using var conn = DBContext.GetOpenConnection();
  69. TrackLinkDTO exist = null;
  70. if (!forceNew)
  71. {
  72. exist = new DBContext.Table(conn, "track_links")
  73. .Get<TrackLinkDTO>("event_type=@event_type AND platform=@platform AND typename=@typename AND scene=@scene AND unique_id=@unique_id",
  74. new { event_type = eventType, platform, typename, scene, unique_id = uniqueId });
  75. }
  76. if (exist != null)
  77. {
  78. exist.description = description;
  79. string path = BuildPath(exist.id, uniqueId);
  80. if (string.IsNullOrEmpty(exist.path) || !exist.path.Contains("track_id"))
  81. {
  82. exist.path = path;
  83. new DBContext.Table(conn, "track_links")
  84. .Add("path", path)
  85. .Add("platform", platform)
  86. .Add("typename", typename)
  87. .Add("description", description)
  88. .Add("update_time", DateTime.Now)
  89. .Where("id=@id", new { exist.id })
  90. .Update();
  91. }
  92. _ = RedisHelper.Set(cacheKey, exist.id, LinkCacheExpireSeconds);
  93. _ = RedisHelper.Set(GetLinkIdCacheKey(exist.id), exist, LinkCacheExpireSeconds);
  94. return Task.FromResult(exist);
  95. }
  96. var item = new TrackLinkDTO
  97. {
  98. event_type = eventType,
  99. platform = platform,
  100. typename = typename,
  101. scene = scene,
  102. unique_id = uniqueId,
  103. path = string.Empty,
  104. description = description,
  105. create_time = DateTime.Now,
  106. update_time = DateTime.Now
  107. };
  108. var id = conn.Insert(item);
  109. if (id != null && int.TryParse(id.ToString(), out var linkId))
  110. {
  111. item.id = linkId;
  112. item.path = BuildPath(linkId, uniqueId);
  113. new DBContext.Table(conn, "track_links")
  114. .Add("path", item.path)
  115. .Add("description", description)
  116. .Add("update_time", DateTime.Now)
  117. .Where("id=@id", new { item.id })
  118. .Update();
  119. }
  120. _ = RedisHelper.Set(cacheKey, item.id, LinkCacheExpireSeconds);
  121. _ = RedisHelper.Set(GetLinkIdCacheKey(item.id), item, LinkCacheExpireSeconds);
  122. return Task.FromResult(item);
  123. }
  124. catch (Exception ex)
  125. {
  126. _ = new LoggerLibrary("TracksCore", "CreateLink")
  127. .Info(ex.Message, ex.StackTrace)
  128. .SaveAsync();
  129. return Task.FromResult<TrackLinkDTO>(null);
  130. }
  131. }
  132. /// <summary>
  133. /// 曝光/点击触发:计入 Redis,失败不影响返回
  134. /// </summary>
  135. public static Task<bool> TrackAsync(TrackLinkDTO link, string scene = "", int accountId = 0)
  136. {
  137. string dateStr = DateTime.Now.ToString("yyyyMMdd");
  138. string hourStr = DateTime.Now.ToString("yyyyMMddHH");
  139. string metricScene = ResolveMetricScene(link, scene);
  140. accountId = Math.Max(accountId, 0);
  141. string indexValue = BuildMetricIndexValue(link.event_type, link.id, metricScene);
  142. var metrics = new List<(string key, string indexKey, string indexValue, int expire)>
  143. {
  144. (BuildDailyCountKey(link.event_type, link.id, dateStr, metricScene), $"{RedisPrefix}:daily:index:{dateStr}:track", indexValue, DailyExpireSeconds),
  145. (BuildHourlyCountKey(link.event_type, link.id, hourStr, metricScene), $"{RedisPrefix}:hour:index:{hourStr}:track", indexValue, HourlyExpireSeconds),
  146. };
  147. if (accountId > 0)
  148. {
  149. string accountIndexValue = BuildMetricIndexValue(link.event_type, link.id, metricScene, accountId);
  150. metrics.Add((BuildDailyCountKey(link.event_type, link.id, dateStr, metricScene, accountId), $"{RedisPrefix}:daily:index:{dateStr}:track", accountIndexValue, DailyExpireSeconds));
  151. metrics.Add((BuildHourlyCountKey(link.event_type, link.id, hourStr, metricScene, accountId), $"{RedisPrefix}:hour:index:{hourStr}:track", accountIndexValue, HourlyExpireSeconds));
  152. }
  153. try
  154. {
  155. foreach (var metric in metrics)
  156. {
  157. RedisHelper.IncrBy(metric.key);
  158. RedisHelper.Expire(metric.key, metric.expire);
  159. RedisHelper.SAdd(metric.indexKey, metric.indexValue);
  160. RedisHelper.Expire(metric.indexKey, metric.expire);
  161. }
  162. return Task.FromResult(true);
  163. }
  164. catch (Exception ex)
  165. {
  166. _ = new LoggerLibrary("TracksCore", "TrackAsync")
  167. .Info(ex.Message, ex.StackTrace)
  168. .SaveAsync();
  169. return Task.FromResult(false);
  170. }
  171. }
  172. /// <summary>
  173. /// 日报:拉取指定日期 Redis 计数并落地到 track_daily_report,可高频重复执行。
  174. /// </summary>
  175. public static async Task<int> FlushDailyAsync(DateTime targetDate)
  176. {
  177. string dateStr = targetDate.ToString("yyyyMMdd");
  178. string indexKey = $"{RedisPrefix}:daily:index:{dateStr}:track";
  179. var indexMembers = await RedisHelper.SMembersAsync<string>(indexKey) ?? [];
  180. if (indexMembers == null || indexMembers.Length == 0) return 0;
  181. var buckets = new Dictionary<string, DailyReportBucket>();
  182. using var conn = DBContext.GetOpenConnection();
  183. var linkCache = new Dictionary<int, TrackLinkDTO>();
  184. foreach (var member in indexMembers)
  185. {
  186. var parts = member.Split('|');
  187. if (parts.Length < 2 || parts.Length > 4) continue;
  188. string eventType = NormalizeEventType(parts[0]);
  189. if (!SupportEventTypes.Contains(eventType)) continue;
  190. if (!int.TryParse(parts[1], out var trackId) || trackId <= 0) continue;
  191. bool hasScenePart = parts.Length >= 3;
  192. string memberScene = hasScenePart ? DecodeIndexPart(parts[2]) : string.Empty;
  193. int accountId = 0;
  194. if (parts.Length == 4 && (!int.TryParse(parts[3], out accountId) || accountId <= 0)) continue;
  195. string countKey = BuildDailyCountKey(eventType, trackId, dateStr, hasScenePart ? memberScene : string.Empty, accountId);
  196. int total = await RedisHelper.GetAsync<int>(countKey);
  197. if (total <= 0) continue;
  198. if (!linkCache.TryGetValue(trackId, out var link))
  199. {
  200. link = new DBContext.Table(conn, "track_links")
  201. .Get<TrackLinkDTO>("id=@id", new { id = trackId });
  202. if (link != null) linkCache[trackId] = link;
  203. }
  204. string reportScene = ResolveMetricScene(link, memberScene);
  205. string bucketKey = BuildMetricIndexValue(eventType, trackId, reportScene, accountId);
  206. if (!buckets.TryGetValue(bucketKey, out var bucket))
  207. {
  208. bucket = new DailyReportBucket
  209. {
  210. EventType = eventType,
  211. TrackId = trackId,
  212. Scene = reportScene,
  213. AccountId = accountId,
  214. Link = link
  215. };
  216. buckets[bucketKey] = bucket;
  217. }
  218. bucket.Total += total;
  219. }
  220. int rows = 0;
  221. foreach (var bucket in buckets.Values)
  222. {
  223. await UpsertDailyReportAsync(conn, targetDate.Date, bucket.EventType, bucket.TrackId, bucket.Scene, bucket.AccountId, bucket.Link, bucket.Total);
  224. rows++;
  225. }
  226. return rows;
  227. }
  228. private static Task<int> UpsertDailyReportAsync(IDbConnection conn, DateTime reportDate, string eventType, int trackId, string scene, int accountId, TrackLinkDTO? link, int total)
  229. {
  230. const string sql = @"
  231. INSERT INTO track_daily_report
  232. (report_date, event_type, track_link_id, account_id, platform, typename, scene, unique_id, event_count, create_time, update_time)
  233. VALUES
  234. (@reportDate, @eventType, @trackId, @accountId, @platform, @typename, @scene, @uniqueId, @eventCount, @now, @now)
  235. ON DUPLICATE KEY UPDATE
  236. event_count = VALUES(event_count),
  237. account_id = VALUES(account_id),
  238. platform = VALUES(platform),
  239. typename = VALUES(typename),
  240. scene = VALUES(scene),
  241. unique_id = VALUES(unique_id),
  242. update_time = VALUES(update_time);";
  243. return conn.ExecuteAsync(sql, new
  244. {
  245. reportDate,
  246. eventType,
  247. trackId,
  248. accountId,
  249. platform = link?.platform ?? string.Empty,
  250. typename = link?.typename ?? string.Empty,
  251. scene,
  252. uniqueId = link?.unique_id ?? string.Empty,
  253. eventCount = total,
  254. now = DateTime.Now
  255. });
  256. }
  257. public static async Task<List<TrackHourlyReportDTO>> GetHourlyReportAsync(int trackId, DateTime targetDate, string scene = "", int accountId = 0)
  258. {
  259. var result = new List<TrackHourlyReportDTO>();
  260. if (trackId <= 0) return result;
  261. var link = await GetLinkByIdAsync(trackId);
  262. if (link == null) return result;
  263. string eventType = NormalizeEventType(link.event_type);
  264. if (!SupportEventTypes.Contains(eventType)) return result;
  265. string metricScene = ResolveMetricScene(link, scene);
  266. string linkScene = ResolveMetricScene(link, string.Empty);
  267. string dateStr = targetDate.ToString("yyyyMMdd");
  268. accountId = Math.Max(accountId, 0);
  269. for (int hour = 0; hour < 24; hour++)
  270. {
  271. string hourStr = $"{dateStr}{hour:00}";
  272. int total = await RedisHelper.GetAsync<int>(BuildHourlyCountKey(eventType, trackId, hourStr, metricScene, accountId));
  273. if (accountId == 0 && !string.IsNullOrEmpty(metricScene) && metricScene == linkScene)
  274. {
  275. total += await RedisHelper.GetAsync<int>(BuildHourlyCountKey(eventType, trackId, hourStr, string.Empty));
  276. }
  277. result.Add(new TrackHourlyReportDTO
  278. {
  279. track_link_id = trackId,
  280. event_type = eventType,
  281. platform = link.platform ?? string.Empty,
  282. typename = link.typename ?? string.Empty,
  283. scene = metricScene,
  284. unique_id = link.unique_id ?? string.Empty,
  285. account_id = accountId,
  286. report_date = targetDate.Date,
  287. hour = hour,
  288. event_count = total
  289. });
  290. }
  291. return result;
  292. }
  293. public static string BuildPath(TrackType type, TkDataDTO result)
  294. {
  295. int trackId = type == TrackType.Click ? 1 : 2;
  296. if (result == null) return string.Empty;
  297. string unique_id = $"1|{result.itemId}_{result.mktId}";
  298. return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
  299. }
  300. public static string BuildPath(TrackType type, JdDataDTO result)
  301. {
  302. int trackId = type == TrackType.Click ? 19 : 20;
  303. if (result == null) return string.Empty;
  304. string unique_id = $"13|{result.shortLinkurl.UrlEncode()}";
  305. return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
  306. }
  307. public static string BuildPath(TrackType type, PddDataDTO result)
  308. {
  309. int trackId = type == TrackType.Click ? 21 : 22;
  310. if (result == null) return string.Empty;
  311. string unique_id = $"9|{result.shortLinkurl.UrlEncode()}";
  312. return BuildPath(trackId, unique_id, GetTrackScene(result.parse_type, result.riskStrategy), result.accountId);
  313. }
  314. public static string BuildBrwSimilarPath(TrackType type, TkDataDTO result, PromotionQueryItemDTO similar_goods = null, int index = 0)
  315. {
  316. int trackId = type == TrackType.Click ? 23 : 24;
  317. if (result == null) return string.Empty;
  318. string unique_id = $"1|{result.itemId}_{result.mktId}";
  319. string scene = string.Empty;
  320. if (similar_goods != null)
  321. {
  322. unique_id = $"1|{result.itemId}_{result.mktId}_{index}";
  323. scene = "similar";
  324. }
  325. return BuildPath(trackId, unique_id, scene, result.accountId);
  326. }
  327. public static string BuildPath(int trackId, string unique_id = "", string scene = "", int accountId = 0)
  328. {
  329. scene = NormalizeReportScene(scene);
  330. string url = $"https://api.molilian.com/tracks/track?track_id={trackId}&unique_id={unique_id}";
  331. if (!string.IsNullOrEmpty(scene)) url += $"&scene={scene.UrlEncode()}";
  332. if (accountId > 0) url += $"&account_id={accountId}";
  333. return url;
  334. }
  335. public static string ResolveMetricScene(TrackLinkDTO? link, string scene = "")
  336. {
  337. scene = NormalizeReportScene(scene);
  338. if (!string.IsNullOrEmpty(scene)) return scene;
  339. return NormalizeOrAll(link?.scene ?? string.Empty);
  340. }
  341. public static string GetLinkCacheKey(string eventType, string typename, string scene, string uniqueId)
  342. {
  343. return GetLinkCacheKey(eventType, string.Empty, typename, scene, uniqueId);
  344. }
  345. public static string GetLinkCacheKey(string eventType, string platform, string typename, string scene, string uniqueId)
  346. {
  347. eventType = NormalizeEventType(eventType);
  348. platform = Normalize(platform);
  349. typename = Normalize(typename);
  350. scene = NormalizeOrAll(scene);
  351. uniqueId = NormalizeOrAll(uniqueId);
  352. return $"{RedisPrefix}:link:{eventType}:{platform}:{typename}:{scene}:{uniqueId}";
  353. }
  354. public static string GetLinkIdCacheKey(int trackId)
  355. {
  356. return $"{RedisPrefix}:linkid:{trackId}";
  357. }
  358. public static async Task<TrackLinkDTO> GetLinkByIdAsync(int trackId)
  359. {
  360. if (trackId <= 0) return null;
  361. string cacheKey = GetLinkIdCacheKey(trackId);
  362. try
  363. {
  364. var cached = RedisHelper.Get<TrackLinkDTO>(cacheKey);
  365. if (cached != null) return cached;
  366. }
  367. catch { }
  368. try
  369. {
  370. var item = new DBContext.Table("track_links").Get<TrackLinkDTO>("id=@id", new { id = trackId });
  371. if (item != null)
  372. {
  373. _ = RedisHelper.Set(cacheKey, item, LinkCacheExpireSeconds);
  374. }
  375. return item;
  376. }
  377. catch
  378. {
  379. return null;
  380. }
  381. }
  382. public static Task<bool> LogTrackRequestAsync(TrackRequestLogDTO dto)
  383. {
  384. try
  385. {
  386. dto.event_type = NormalizeEventType(dto.event_type);
  387. dto.platform = Normalize(dto.platform);
  388. dto.typename = Normalize(dto.typename);
  389. dto.scene = NormalizeOrAll(dto.scene);
  390. dto.unique_id = NormalizeOrAll(dto.unique_id);
  391. dto.ip = Normalize(dto.ip);
  392. dto.user_agent = Normalize(dto.user_agent);
  393. dto.referer = Normalize(dto.referer);
  394. dto.create_time = DateTime.Now;
  395. RedisHelper.RPush(TrackRequestLogKey, dto);
  396. return Task.FromResult(true);
  397. }
  398. catch
  399. {
  400. return Task.FromResult(false);
  401. }
  402. }
  403. public static async Task<int> InsertTrackRequestLogAsync(int limit, YunhuiKit.RedisClient redis)
  404. {
  405. int count = 0;
  406. try
  407. {
  408. using var conn = DBContext.GetOpenConnection();
  409. for (int i = 0; i < limit; i++)
  410. {
  411. var entity = await redis.LPopAsync<TrackRequestLogDTO>(TrackRequestLogKey);
  412. if (entity == null) break;
  413. try
  414. {
  415. if (entity.create_time == default) entity.create_time = DateTime.Now;
  416. conn.Insert(entity);
  417. count++;
  418. }
  419. catch
  420. {
  421. // ignore malformed item
  422. }
  423. }
  424. }
  425. catch
  426. {
  427. return count;
  428. }
  429. return count;
  430. }
  431. private static string Normalize(string value)
  432. {
  433. return (value ?? string.Empty).Trim();
  434. }
  435. private static string NormalizeReportScene(string value)
  436. {
  437. var scene = Normalize(value);
  438. return scene == "默认场景" ? string.Empty : scene;
  439. }
  440. private static string NormalizeOrAll(string value)
  441. {
  442. var result = Normalize(value);
  443. return string.IsNullOrEmpty(result) ? DefaultDimensionValue : result;
  444. }
  445. private static string NormalizeEventType(string value)
  446. {
  447. return Normalize(value).ToLowerInvariant();
  448. }
  449. private static string GetTrackScene(string parseType, string riskStrategy)
  450. {
  451. parseType = NormalizeReportScene(parseType);
  452. if (!string.IsNullOrEmpty(parseType)) return parseType;
  453. riskStrategy = NormalizeReportScene(riskStrategy);
  454. if (!string.IsNullOrEmpty(riskStrategy)) return riskStrategy;
  455. return DefaultDimensionValue;
  456. }
  457. private static string BuildDailyCountKey(string eventType, int trackId, string dateStr, string scene, int accountId = 0)
  458. {
  459. eventType = NormalizeEventType(eventType);
  460. scene = NormalizeReportScene(scene);
  461. string key = $"{RedisPrefix}:daily:{eventType}:{trackId}:{dateStr}";
  462. if (!string.IsNullOrEmpty(scene)) key += $":{EncodeIndexPart(scene)}";
  463. if (accountId > 0) key += $":account:{accountId}";
  464. return key;
  465. }
  466. private static string BuildHourlyCountKey(string eventType, int trackId, string hourStr, string scene, int accountId = 0)
  467. {
  468. eventType = NormalizeEventType(eventType);
  469. scene = NormalizeReportScene(scene);
  470. string key = $"{RedisPrefix}:hour:{eventType}:{trackId}:{hourStr}";
  471. if (!string.IsNullOrEmpty(scene)) key += $":{EncodeIndexPart(scene)}";
  472. if (accountId > 0) key += $":account:{accountId}";
  473. return key;
  474. }
  475. private static string BuildMetricIndexValue(string eventType, int trackId, string scene, int accountId = 0)
  476. {
  477. eventType = NormalizeEventType(eventType);
  478. scene = NormalizeReportScene(scene);
  479. if (accountId > 0) return $"{eventType}|{trackId}|{EncodeIndexPart(scene)}|{accountId}";
  480. if (string.IsNullOrEmpty(scene)) return $"{eventType}|{trackId}";
  481. return $"{eventType}|{trackId}|{EncodeIndexPart(scene)}";
  482. }
  483. private static string EncodeIndexPart(string value)
  484. {
  485. return Normalize(value).UrlEncode();
  486. }
  487. private static string DecodeIndexPart(string value)
  488. {
  489. try
  490. {
  491. return Normalize(value).UrlDecode();
  492. }
  493. catch
  494. {
  495. return Normalize(value);
  496. }
  497. }
  498. private sealed class DailyReportBucket
  499. {
  500. public string EventType { get; set; } = string.Empty;
  501. public int TrackId { get; set; }
  502. public string Scene { get; set; } = string.Empty;
  503. public int AccountId { get; set; }
  504. public TrackLinkDTO? Link { get; set; }
  505. public int Total { get; set; }
  506. }
  507. private static int GetTrackLinkId(IDbConnection conn, string eventType, string platform, string scene, string uniqueId)
  508. {
  509. try
  510. {
  511. var record = new DBContext.Table(conn, "track_links")
  512. .Fields("id")
  513. .Get<TrackLinkDTO>("event_type=@event_type AND platform=@platform AND scene=@scene AND unique_id=@unique_id",
  514. new { event_type = eventType, platform, scene, unique_id = uniqueId });
  515. if (record == null) return 0;
  516. return record.id;
  517. }
  518. catch
  519. {
  520. return 0;
  521. }
  522. }
  523. }
  524. }