TkEndpointManager.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313
  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. public static void Refresh()
  14. {
  15. accountStates.Clear();
  16. lastSuspendTimes.Clear();
  17. }
  18. // 从数据库获取所有可用的API端点
  19. private static async Task<Dictionary<int, string>> GetAllAvailableApisAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoint = null)
  20. {
  21. var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
  22. return endpoints?
  23. .Where(e => e.status)
  24. .ToDictionary(e => e.ep_id, e => e.endpoint) ?? new Dictionary<int, string>();
  25. }
  26. // 从数据库获取端点的挂起时间配置
  27. private static async Task<ConcurrentDictionary<string, int>> GetEndpointHoldMinutesAsync(int accountId, bool? isTaobaoUrl = null, string parseEndpoint = null)
  28. {
  29. var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
  30. var dict = new ConcurrentDictionary<string, int>();
  31. if (endpoints != null)
  32. {
  33. foreach (var endpoint in endpoints)
  34. {
  35. dict.TryAdd(endpoint.endpoint, endpoint.suspend_duration);
  36. }
  37. }
  38. return dict;
  39. }
  40. public static bool IsEndpointSuspended(int accountId, string endpoint)
  41. {
  42. return RedisHelper.Exists($"tk_suspend:{accountId}:{endpoint}");
  43. }
  44. /// <summary>
  45. /// 检查指定账号是否至少拥有一个可用的endpoint
  46. /// </summary>
  47. /// <param name="accountId">账号ID</param>
  48. /// <returns>true如果至少有一个可用endpoint,否则false</returns>
  49. public static async Task<bool> HasAvailableEndpointAsync(int accountId)
  50. {
  51. var availableApis = await GetAllAvailableApisAsync(accountId);
  52. if (availableApis.Count == 0) return false;
  53. if (!accountStates.TryGetValue(accountId, out var state))
  54. {
  55. return true; // 如果账号没有状态记录,所有节点都是正常的
  56. }
  57. return availableApis.Any(api => !IsEndpointSuspended(accountId, api.Value));
  58. }
  59. /// <summary>
  60. /// 获取多个账号的节点状态信息
  61. /// </summary>
  62. public static async Task<Dictionary<int, Dictionary<string, string>>> GetStatus(IEnumerable<int> accountIds)
  63. {
  64. var result = new Dictionary<int, Dictionary<string, string>>();
  65. foreach (var accountId in accountIds.Distinct())
  66. {
  67. var availableApis = await GetAllAvailableApisAsync(accountId);
  68. result[accountId] = await GetStatus(accountId, availableApis);
  69. }
  70. return result;
  71. }
  72. /// <summary>
  73. /// 获取指定账号的节点状态信息
  74. /// </summary>
  75. public static async Task<Dictionary<string, string>> GetStatus(int accountId, Dictionary<int, string> availableApis)
  76. {
  77. var status = new Dictionary<string, string>();
  78. if (availableApis.Count == 0)
  79. {
  80. return status;
  81. }
  82. if (!accountStates.TryGetValue(accountId, out var state))
  83. {
  84. // 如果账号没有状态记录,所有节点都是正常的
  85. foreach ((var api_id, var api) in availableApis)
  86. {
  87. status[api] = "正常";
  88. }
  89. return status;
  90. }
  91. foreach ((var api_id, var api) in availableApis)
  92. {
  93. if (IsEndpointSuspended(accountId, api))
  94. {
  95. // Redis没有挂起到期时间,展示"挂起"即可
  96. status[api] = $"挂起";
  97. }
  98. else
  99. {
  100. status[api] = "正常";
  101. }
  102. }
  103. return status;
  104. }
  105. public static async Task<(int, string)> GetConvertApiAsync(int accountId, bool isTaobaoUrl, string parseEndpoint)
  106. {
  107. var endpoints = await TkEndpointCore.GetEndpointsByAccountAsync(accountId, isTaobaoUrl, parseEndpoint);
  108. if (endpoints == null || !endpoints.Any(e => e.status)) return (0, string.Empty);
  109. // 1. 过滤可用端点(状态正常)
  110. var availableApis = endpoints.Where(e => e.status);
  111. var state = accountStates.GetOrAdd(accountId, _ => new AccountEndpointState());
  112. var now = DateTime.Now;
  113. // 2. 先找出所有未被挂起、未达限制的端点
  114. var availableEndpoints = availableApis
  115. .Where(e => !IsEndpointSuspended(accountId, e.endpoint))
  116. .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
  117. .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
  118. .ToArray();
  119. // 3. 如果存在可用端点,直接返回
  120. if (availableEndpoints.Length > 0)
  121. {
  122. // 生成唯一的轮询key,确保每种可用端点组合都有独立索引
  123. string roundRobinKey = $"{accountId}:{string.Join(",", availableEndpoints.Select(e => e.ep_id).OrderBy(x => x))}";
  124. var currentIndex = state.GetOrAddAccountIndex(roundRobinKey);
  125. var newIndex = (currentIndex + 1) % availableEndpoints.Length;
  126. state.UpdateAccountIndex(roundRobinKey, newIndex);
  127. var selectedEndpoint = availableEndpoints[newIndex];
  128. // 异步更新调用统计(不阻塞当前请求)
  129. _ = UpdateEndpointStatsAsync(accountId, selectedEndpoint, now);
  130. return (selectedEndpoint.ep_id, selectedEndpoint.endpoint);
  131. }
  132. // 4. 检查是否仅因时间间隔限制
  133. var intervalLimitedEndpoints = availableApis
  134. .Where(e => !IsEndpointSuspended(accountId, e.endpoint))
  135. .Where(e => e.hourly_calls_limit <= 0 || e.current_hourly_calls < e.hourly_calls_limit)
  136. .Where(e => e.daily_calls_limit <= 0 || e.current_daily_calls < e.daily_calls_limit)
  137. .Where(e => e.interval_seconds > 0 &&
  138. e.last_call_time != null &&
  139. (now - e.last_call_time).TotalSeconds < e.interval_seconds)
  140. .ToArray();
  141. if (intervalLimitedEndpoints.Length > 0)
  142. {
  143. // 如果是时间间隔限制,返回空字符串
  144. return (0, string.Empty);
  145. }
  146. // 5. 检查所有端点的状态
  147. var allEndpointsSuspended = availableApis.All(e => IsEndpointSuspended(accountId, e.endpoint));
  148. var allEndpointsLimited = availableApis.All(e =>
  149. (e.hourly_calls_limit > 0 && e.current_hourly_calls >= e.hourly_calls_limit) ||
  150. (e.daily_calls_limit > 0 && e.current_daily_calls >= e.daily_calls_limit));
  151. // 如果所有端点都被挂起或达到限制,返回 "ALL"
  152. if (allEndpointsSuspended || allEndpointsLimited)
  153. {
  154. return (0, "ALL");
  155. }
  156. // 6. 其他情况(理论上不应该到达这里)
  157. return (0, string.Empty);
  158. }
  159. // 辅助方法:异步更新调用统计(原逻辑,仅拆分以保持清晰)
  160. private static async Task UpdateEndpointStatsAsync(int accountId, TkEndpointConfigDTO selectedEndpoint, DateTime now)
  161. {
  162. var currentHour = now.ToString("yyyyMMddHH");
  163. var currentDay = now.ToString("yyyyMMdd");
  164. // 更新调用计数
  165. selectedEndpoint.current_hourly_calls++;
  166. selectedEndpoint.current_daily_calls++;
  167. selectedEndpoint.last_call_time = now;
  168. // 保存到 Redis
  169. await RiskControlCore.SetTkEndpointCallsAsync(accountId, selectedEndpoint.id, currentHour, selectedEndpoint.current_hourly_calls);
  170. await RiskControlCore.SetTkEndpointCallsAsync(accountId, selectedEndpoint.id, currentDay, selectedEndpoint.current_daily_calls);
  171. // 检查是否需要挂起(原逻辑)
  172. if (selectedEndpoint.hourly_calls_limit > 0 && selectedEndpoint.current_hourly_calls >= selectedEndpoint.hourly_calls_limit)
  173. {
  174. _ = SuspendAsync(accountId, selectedEndpoint.endpoint, SuspendReason.HourlyLimit, selectedEndpoint.suspend_duration);
  175. }
  176. if (selectedEndpoint.daily_calls_limit > 0 && selectedEndpoint.current_daily_calls >= selectedEndpoint.daily_calls_limit)
  177. {
  178. _ = SuspendAsync(accountId, selectedEndpoint.endpoint, SuspendReason.DailyLimit, selectedEndpoint.suspend_duration);
  179. }
  180. }
  181. public static async Task SuspendAsync(int accountId, string endpoint, SuspendReason reason = SuspendReason.Passive, int? customHoldMinutes = null)
  182. {
  183. var holdMinutes = await GetEndpointHoldMinutesAsync(accountId);
  184. TimeSpan suspendDuration;
  185. if (customHoldMinutes.HasValue)
  186. {
  187. suspendDuration = TimeSpan.FromMinutes(customHoldMinutes.Value);
  188. }
  189. else
  190. {
  191. suspendDuration = reason switch
  192. {
  193. SuspendReason.HourlyLimit => CalculateHourlySuspendDuration(),
  194. SuspendReason.DailyLimit => CalculateDailySuspendDuration(),
  195. _ => TimeSpan.FromMinutes(holdMinutes.GetValueOrDefault(endpoint, 240))
  196. };
  197. }
  198. RedisHelper.Set($"tk_suspend:{accountId}:{endpoint}", 1, (int)suspendDuration.TotalSeconds);
  199. _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}_{endpoint}", reason.ToString()).SaveAsync();
  200. _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId}_{endpoint} ({reason})");
  201. }
  202. /// <summary>
  203. /// 主动释放指定账号的挂起状态
  204. /// </summary>
  205. /// <param name="accountId">账号ID</param>
  206. /// <param name="endpoint">要释放的端点名称,如果为null则释放所有端点的挂起状态</param>
  207. public static void ReleaseSuspend(int accountId, string endpoint = null)
  208. {
  209. if (endpoint == null)
  210. {
  211. var pattern = $"tk_suspend:{accountId}:*";
  212. var keys = RedisHelper.Keys(pattern);
  213. foreach (var key in keys)
  214. {
  215. RedisHelper.Del(key);
  216. }
  217. }
  218. else
  219. {
  220. RedisHelper.Del($"tk_suspend:{accountId}:{endpoint}");
  221. }
  222. var action = endpoint == null ? "释放所有挂起" : $"释放挂起({endpoint})";
  223. _ = new LoggerLibrary("转链接口风控", accountId.ToString()).Info($"{accountId}", action).SaveAsync();
  224. _ = NotifyCore.NotifyAsync($"【转链接口风控】{accountId} {action}");
  225. }
  226. private static TimeSpan CalculateHourlySuspendDuration()
  227. {
  228. var now = DateTime.Now;
  229. var nextHour = now.AddHours(1).Date.AddHours(now.Hour + 1); // 下一个整点(如 14:30 → 15:00)
  230. return nextHour - now;
  231. }
  232. private static TimeSpan CalculateDailySuspendDuration()
  233. {
  234. var now = DateTime.Now;
  235. var tomorrow = now.Date.AddDays(1); // 次日零点
  236. return tomorrow - now;
  237. }
  238. public enum SuspendReason
  239. {
  240. /// <summary>
  241. /// 主动暂停(手动操作)
  242. /// </summary>
  243. Active,
  244. /// <summary>
  245. /// 风控导致的被动暂停
  246. /// </summary>
  247. Passive,
  248. /// <summary>
  249. /// 每小时总量限制触发的暂停
  250. /// </summary>
  251. HourlyLimit,
  252. /// <summary>
  253. /// 每日总量限制触发的暂停
  254. /// </summary>
  255. DailyLimit
  256. }
  257. private class AccountEndpointState
  258. {
  259. // 只保留轮询索引
  260. private readonly ConcurrentDictionary<string, int> roundRobinIndices = new();
  261. public int GetOrAddAccountIndex(string key) => roundRobinIndices.GetOrAdd(key, -1);
  262. public void UpdateAccountIndex(string key, int newIndex) => roundRobinIndices[key] = newIndex;
  263. }
  264. }