TkEndpointManager.cs 19 KB

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