TkEndpointManager.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. using dodohold.core;
  2. using Google.Protobuf.WellKnownTypes;
  3. using molilian.core;
  4. using System;
  5. using System.Collections.Concurrent;
  6. using System.Linq;
  7. public partial class TkEndpointManager
  8. {
  9. // 只保留轮询索引
  10. private static readonly ConcurrentDictionary<int, AccountEndpointState> accountStates = new();
  11. // 记录每个账号最后一次挂起操作的时间
  12. private static readonly ConcurrentDictionary<int, DateTime> lastSuspendTimes = new();
  13. // 短时缓存挂起状态,削峰同一波请求对 Redis Exists 的放大访问
  14. private static readonly ConcurrentDictionary<string, SuspendCacheEntry> suspendStateCache = new();
  15. private static readonly ConcurrentDictionary<string, byte> suspendStateRefreshInFlight = new();
  16. private static readonly TimeSpan SuspendStateCacheDuration = TimeSpan.FromSeconds(10);
  17. private static readonly TimeSpan SuspendStateFailureBackoffDuration = TimeSpan.FromSeconds(3);
  18. public static void Refresh()
  19. {
  20. accountStates.Clear();
  21. lastSuspendTimes.Clear();
  22. suspendStateCache.Clear();
  23. suspendStateRefreshInFlight.Clear();
  24. }
  25. // 从数据库获取所有可用的API端点
  26. private static async Task<Dictionary<int, string>> GetAllAvailableApisAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoint = null)
  27. {
  28. var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
  29. return endpoints?
  30. .Where(e => e.status)
  31. .ToDictionary(e => e.ep_id, e => e.endpoint) ?? new Dictionary<int, string>();
  32. }
  33. // 从数据库获取端点的挂起时间配置
  34. private static async Task<ConcurrentDictionary<string, int>> GetEndpointHoldMinutesAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoint = null)
  35. {
  36. var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
  37. var dict = new ConcurrentDictionary<string, int>();
  38. if (endpoints != null)
  39. {
  40. foreach (var endpoint in endpoints)
  41. {
  42. dict.TryAdd(endpoint.endpoint, endpoint.suspend_duration);
  43. }
  44. }
  45. return dict;
  46. }
  47. public static bool IsEndpointSuspended(int accountId, string endpoint)
  48. {
  49. if (string.IsNullOrWhiteSpace(endpoint)) return false;
  50. string key = BuildSuspendKey(accountId, endpoint);
  51. if (TryGetCachedSuspendState(key, out var isSuspended))
  52. return isSuspended;
  53. // 热路径不再同步访问 Redis。缓存未命中时先降级放行,再异步探测 Redis 状态。
  54. _ = RefreshSuspendStateAsync(key);
  55. CacheSuspendState(key, false, SuspendStateFailureBackoffDuration);
  56. return false;
  57. }
  58. /// <summary>
  59. /// 检查指定账号是否至少拥有一个可用的endpoint
  60. /// </summary>
  61. /// <param name="accountId">账号ID</param>
  62. /// <returns>true如果至少有一个可用endpoint,否则false</returns>
  63. public static async Task<bool> HasAvailableEndpointAsync(int accountId)
  64. {
  65. var availableApis = await GetAllAvailableApisAsync(accountId);
  66. if (availableApis.Count == 0) return false;
  67. if (!accountStates.TryGetValue(accountId, out var state))
  68. {
  69. return true; // 如果账号没有状态记录,所有节点都是正常的
  70. }
  71. var suspendStates = GetSuspendStates(accountId, availableApis.Values);
  72. return availableApis.Any(api => !suspendStates.GetValueOrDefault(api.Value));
  73. }
  74. /// <summary>
  75. /// 获取多个账号的节点状态信息
  76. /// </summary>
  77. public static async Task<Dictionary<int, Dictionary<string, string>>> GetStatus(IEnumerable<int> accountIds)
  78. {
  79. var result = new Dictionary<int, Dictionary<string, string>>();
  80. foreach (var accountId in accountIds.Distinct())
  81. {
  82. var availableApis = await GetAllAvailableApisAsync(accountId);
  83. result[accountId] = await GetStatus(accountId, availableApis);
  84. }
  85. return result;
  86. }
  87. /// <summary>
  88. /// 获取指定账号的节点状态信息
  89. /// </summary>
  90. public static async Task<Dictionary<string, string>> GetStatus(int accountId, Dictionary<int, string> availableApis)
  91. {
  92. var status = new Dictionary<string, string>();
  93. if (availableApis.Count == 0)
  94. {
  95. return status;
  96. }
  97. if (!accountStates.TryGetValue(accountId, out var state))
  98. {
  99. // 如果账号没有状态记录,所有节点都是正常的
  100. foreach ((var api_id, var api) in availableApis)
  101. {
  102. status[api] = "正常";
  103. }
  104. return status;
  105. }
  106. var suspendStates = GetSuspendStates(accountId, availableApis.Values);
  107. foreach ((var api_id, var api) in availableApis)
  108. {
  109. if (suspendStates.GetValueOrDefault(api))
  110. {
  111. // Redis没有挂起到期时间,展示"挂起"即可
  112. status[api] = $"挂起";
  113. }
  114. else
  115. {
  116. status[api] = "正常";
  117. }
  118. }
  119. return status;
  120. }
  121. public static async Task<(int, string)> GetConvertApiAsync(int accountId, bool isTaobaoUrl, string parseEndpoint)
  122. {
  123. var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
  124. if (endpoints == null || !endpoints.Any(e => e.status)) return (0, string.Empty);
  125. // 1. 过滤可用端点(状态正常)
  126. var availableApis = endpoints.Where(e => e.status);
  127. var suspendStates = GetSuspendStates(accountId, availableApis.Select(e => e.endpoint));
  128. var state = accountStates.GetOrAdd(accountId, _ => new AccountEndpointState());
  129. var now = DateTime.Now;
  130. // 2. 先找出所有未被挂起、未达限制的端点
  131. var availableEndpoints = availableApis
  132. .Where(e => !suspendStates.GetValueOrDefault(e.endpoint))
  133. .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
  134. .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
  135. .ToArray();
  136. // 3. 如果存在可用端点,直接返回
  137. if (availableEndpoints.Length > 0)
  138. {
  139. // 生成唯一的轮询key,确保每种可用端点组合都有独立索引
  140. string roundRobinKey = $"{accountId}:{string.Join(",", availableEndpoints.Select(e => e.ep_id).OrderBy(x => x))}";
  141. var currentIndex = state.GetOrAddAccountIndex(roundRobinKey);
  142. var newIndex = (currentIndex + 1) % availableEndpoints.Length;
  143. state.UpdateAccountIndex(roundRobinKey, newIndex);
  144. var selectedEndpoint = availableEndpoints[newIndex];
  145. // 异步更新调用统计(不阻塞当前请求)
  146. _ = UpdateEndpointStatsAsync(accountId, selectedEndpoint, now);
  147. return (selectedEndpoint.ep_id, selectedEndpoint.endpoint);
  148. }
  149. // 4. 检查是否仅因时间间隔限制
  150. var intervalLimitedEndpoints = availableApis
  151. .Where(e => !suspendStates.GetValueOrDefault(e.endpoint))
  152. .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
  153. .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
  154. .Where(e => e.interval_seconds > 0 &&
  155. e.last_call_time != null &&
  156. (now - e.last_call_time).TotalSeconds < e.interval_seconds)
  157. .ToArray();
  158. if (intervalLimitedEndpoints.Length > 0)
  159. {
  160. // 如果是时间间隔限制,返回空字符串
  161. return (0, string.Empty);
  162. }
  163. // 5. 检查所有端点的状态
  164. var allEndpointsSuspended = availableApis.All(e => suspendStates.GetValueOrDefault(e.endpoint));
  165. var allEndpointsLimited = availableApis.All(e =>
  166. (e.hourly_calls_limit > 0 && e.current_hourly_calls >= e.hourly_calls_limit) ||
  167. (e.daily_calls_limit > 0 && e.current_daily_calls >= e.daily_calls_limit));
  168. // 如果所有端点都被挂起或达到限制,返回 "ALL"
  169. if (allEndpointsSuspended || allEndpointsLimited)
  170. {
  171. return (0, "ALL");
  172. }
  173. // 6. 其他情况(理论上不应该到达这里)
  174. return (0, string.Empty);
  175. }
  176. // 辅助方法:异步更新调用统计(原逻辑,仅拆分以保持清晰)
  177. private static async Task UpdateEndpointStatsAsync(int accountId, TkEndpointConfigDTO selectedEndpoint, DateTime now)
  178. {
  179. var currentHour = now.ToString("yyyyMMddHH");
  180. var currentDay = now.ToString("yyyyMMdd");
  181. // 更新调用计数
  182. selectedEndpoint.current_hourly_calls++;
  183. selectedEndpoint.current_daily_calls++;
  184. selectedEndpoint.last_call_time = now;
  185. // 保存到 Redis
  186. await RiskControlCore.SetTkEndpointCallsAsync(accountId, selectedEndpoint.id, currentHour, selectedEndpoint.current_hourly_calls);
  187. await RiskControlCore.SetTkEndpointCallsAsync(accountId, selectedEndpoint.id, currentDay, selectedEndpoint.current_daily_calls);
  188. // 检查是否需要挂起(原逻辑)
  189. if (selectedEndpoint.hourly_calls_limit > 0 && selectedEndpoint.current_hourly_calls >= selectedEndpoint.hourly_calls_limit)
  190. {
  191. _ = SuspendAsync(accountId, selectedEndpoint.endpoint, SuspendReason.HourlyLimit, selectedEndpoint.suspend_duration);
  192. }
  193. if (selectedEndpoint.daily_calls_limit > 0 && selectedEndpoint.current_daily_calls >= selectedEndpoint.daily_calls_limit)
  194. {
  195. _ = SuspendAsync(accountId, selectedEndpoint.endpoint, SuspendReason.DailyLimit, selectedEndpoint.suspend_duration);
  196. }
  197. }
  198. public static async Task SuspendAsync(int accountId, string endpoint, SuspendReason reason = SuspendReason.Passive, int? customHoldMinutes = null, bool notifyOtherNodes = true)
  199. {
  200. var holdMinutes = await GetEndpointHoldMinutesAsync(accountId);
  201. TimeSpan suspendDuration;
  202. if (customHoldMinutes.HasValue)
  203. {
  204. suspendDuration = TimeSpan.FromMinutes(customHoldMinutes.Value);
  205. }
  206. else
  207. {
  208. suspendDuration = reason switch
  209. {
  210. SuspendReason.HourlyLimit => CalculateHourlySuspendDuration(),
  211. SuspendReason.DailyLimit => CalculateDailySuspendDuration(),
  212. _ => TimeSpan.FromMinutes(holdMinutes.GetValueOrDefault(endpoint, 240))
  213. };
  214. }
  215. await SuspendForDurationAsync(accountId, endpoint, suspendDuration, reason, notifyOtherNodes);
  216. }
  217. public static async Task SuspendForDurationAsync(int accountId, string endpoint, TimeSpan suspendDuration, SuspendReason reason = SuspendReason.Passive, bool notifyOtherNodes = true)
  218. {
  219. if (string.IsNullOrWhiteSpace(endpoint)) return;
  220. string key = BuildSuspendKey(accountId, endpoint);
  221. CacheSuspendState(key, true, suspendDuration);
  222. try
  223. {
  224. RedisHelper.Set(key, 1, (int)Math.Ceiling(suspendDuration.TotalSeconds));
  225. }
  226. catch (Exception)
  227. {
  228. // 本地状态已写入,Redis 持久化失败时只降级,不阻塞主流程
  229. }
  230. if (notifyOtherNodes)
  231. {
  232. _ = EndPointCore.NotifyChangeSuspend(accountId, endpoint, release: false, durationSeconds: (int)Math.Ceiling(suspendDuration.TotalSeconds));
  233. }
  234. _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}_{endpoint}", reason.ToString()).SaveAsync();
  235. _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId}_{endpoint} ({reason})");
  236. }
  237. /// <summary>
  238. /// 主动释放指定账号的挂起状态
  239. /// </summary>
  240. /// <param name="accountId">账号ID</param>
  241. /// <param name="endpoint">要释放的端点名称,如果为null则释放所有端点的挂起状态</param>
  242. public static void ReleaseSuspend(int accountId, string endpoint = null, bool notifyOtherNodes = true)
  243. {
  244. if (endpoint == null)
  245. {
  246. foreach (var key in suspendStateCache.Keys.Where(key => key.StartsWith($"tk_suspend:{accountId}:", StringComparison.Ordinal)))
  247. {
  248. suspendStateCache.TryRemove(key, out _);
  249. }
  250. try
  251. {
  252. var pattern = $"tk_suspend:{accountId}:*";
  253. var keys = RedisHelper.Keys(pattern);
  254. foreach (var key in keys)
  255. {
  256. RedisHelper.Del(key);
  257. }
  258. }
  259. catch (Exception)
  260. {
  261. // Redis 不可用时只清理本地状态
  262. }
  263. }
  264. else
  265. {
  266. string key = BuildSuspendKey(accountId, endpoint);
  267. CacheSuspendState(key, false, SuspendStateCacheDuration);
  268. try
  269. {
  270. RedisHelper.Del(key);
  271. }
  272. catch (Exception)
  273. {
  274. // Redis 不可用时只清理本地状态
  275. }
  276. }
  277. if (notifyOtherNodes)
  278. {
  279. _ = EndPointCore.NotifyChangeSuspend(accountId, endpoint, release: true);
  280. }
  281. var action = endpoint == null ? "释放所有挂起" : $"释放挂起({endpoint})";
  282. _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}", action).SaveAsync();
  283. _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId} {action}");
  284. }
  285. private static TimeSpan CalculateHourlySuspendDuration()
  286. {
  287. var now = DateTime.Now;
  288. var nextHour = now.AddHours(1).Date.AddHours(now.Hour + 1); // 下一个整点(如 14:30 → 15:00)
  289. return nextHour - now;
  290. }
  291. private static TimeSpan CalculateDailySuspendDuration()
  292. {
  293. var now = DateTime.Now;
  294. var tomorrow = now.Date.AddDays(1); // 次日零点
  295. return tomorrow - now;
  296. }
  297. public enum SuspendReason
  298. {
  299. /// <summary>
  300. /// 主动暂停(手动操作)
  301. /// </summary>
  302. Active,
  303. /// <summary>
  304. /// 风控导致的被动暂停
  305. /// </summary>
  306. Passive,
  307. /// <summary>
  308. /// 每小时总量限制触发的暂停
  309. /// </summary>
  310. HourlyLimit,
  311. /// <summary>
  312. /// 每日总量限制触发的暂停
  313. /// </summary>
  314. DailyLimit
  315. }
  316. private class AccountEndpointState
  317. {
  318. // 只保留轮询索引
  319. private readonly ConcurrentDictionary<string, int> roundRobinIndices = new();
  320. public int GetOrAddAccountIndex(string key) => roundRobinIndices.GetOrAdd(key, -1);
  321. public void UpdateAccountIndex(string key, int newIndex) => roundRobinIndices[key] = newIndex;
  322. }
  323. private static string BuildSuspendKey(int accountId, string endpoint) => $"tk_suspend:{accountId}:{endpoint}";
  324. private static Dictionary<string, bool> GetSuspendStates(int accountId, IEnumerable<string> endpoints)
  325. {
  326. var result = new Dictionary<string, bool>(StringComparer.Ordinal);
  327. foreach (var endpoint in endpoints.Where(e => !string.IsNullOrWhiteSpace(e)).Distinct(StringComparer.Ordinal))
  328. {
  329. result[endpoint] = IsEndpointSuspended(accountId, endpoint);
  330. }
  331. return result;
  332. }
  333. private static bool TryGetCachedSuspendState(string key, out bool isSuspended)
  334. {
  335. if (suspendStateCache.TryGetValue(key, out var entry))
  336. {
  337. if (entry.ExpiresAtUtc > DateTime.UtcNow)
  338. {
  339. isSuspended = entry.IsSuspended;
  340. return true;
  341. }
  342. suspendStateCache.TryRemove(key, out _);
  343. }
  344. isSuspended = false;
  345. return false;
  346. }
  347. private static void CacheSuspendState(string key, bool isSuspended, TimeSpan duration)
  348. {
  349. if (duration <= TimeSpan.Zero)
  350. {
  351. suspendStateCache.TryRemove(key, out _);
  352. return;
  353. }
  354. suspendStateCache[key] = new SuspendCacheEntry(isSuspended, DateTime.UtcNow.Add(duration));
  355. }
  356. private static Task RefreshSuspendStateAsync(string key)
  357. {
  358. if (!suspendStateRefreshInFlight.TryAdd(key, 0))
  359. return Task.CompletedTask;
  360. return Task.Run(() =>
  361. {
  362. try
  363. {
  364. bool isSuspended = RedisHelper.Exists(key);
  365. CacheSuspendState(
  366. key,
  367. isSuspended,
  368. isSuspended ? SuspendStateCacheDuration : SuspendStateFailureBackoffDuration);
  369. }
  370. catch (Exception)
  371. {
  372. CacheSuspendState(key, false, SuspendStateFailureBackoffDuration);
  373. }
  374. finally
  375. {
  376. suspendStateRefreshInFlight.TryRemove(key, out _);
  377. }
  378. });
  379. }
  380. private readonly record struct SuspendCacheEntry(bool IsSuspended, DateTime ExpiresAtUtc);
  381. }