using System.Collections.Concurrent; using System.Net; using dy.net.model.dto; using dy.net.model.entity; using dy.net.model.response; using dy.net.utils; using Newtonsoft.Json.Linq; using Serilog; using SqlSugar; namespace dy.net.service { public sealed class DouyinLiveStatusService { private static readonly TimeSpan RefreshInterval = TimeSpan.FromMinutes(5); private static readonly TimeSpan ManualRefreshFloor = TimeSpan.FromSeconds(30); private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(10); private static readonly ConcurrentDictionary NotificationGates = new(); private readonly ISqlSugarClient _db; private readonly IDouyinLiveStatusClient _client; private readonly LiveEmailNotificationService _emailNotifications; public DouyinLiveStatusService( ISqlSugarClient db, IDouyinLiveStatusClient client, LiveEmailNotificationService emailNotifications) { _db = db; _client = client; _emailNotifications = emailNotifications; } public async Task SetMonitorAsync( FollowLiveMonitorUpdateDto request, CancellationToken cancellationToken = default) { if (request == null || string.IsNullOrWhiteSpace(request.Id)) throw new InvalidOperationException("博主记录不能为空"); var follow = await RequireFollowAsync(request.Id); if (request.Enabled && string.IsNullOrWhiteSpace(follow.SecUid)) throw new InvalidOperationException("该博主缺少 SecUid,无法开启直播监测"); follow.LiveMonitorEnabled = request.Enabled; if (!request.Enabled) { follow.LiveStatus = DouyinLiveStatusState.Unknown; follow.LiveRoomId = null; follow.LiveWebRid = null; follow.LiveTitle = null; follow.LiveStartedAt = null; follow.LiveCheckedAt = null; follow.LiveStatusUpdatedAt = null; follow.LiveCheckError = null; follow.LiveEmailNotificationEnabled = false; follow.LastLiveNotificationKey = null; follow.LastLiveNotificationAttemptAt = null; follow.LastLiveNotificationError = null; } else if (!follow.LiveCheckedAt.HasValue) { follow.LiveStatus = DouyinLiveStatusState.Unknown; follow.LiveCheckError = null; } await UpdateFollowLiveColumnsAsync(follow); if (!request.Enabled) await UpdateFollowNotificationColumnsAsync(follow); if (request.Enabled) follow = await ProbeFollowAsync(follow, ignoreRecentCheck: true, cancellationToken); return ToDto(follow); } public async Task SetEmailNotificationAsync(FollowLiveEmailUpdateDto request) { if (request == null || string.IsNullOrWhiteSpace(request.Id)) throw new InvalidOperationException("博主记录不能为空"); var follow = await RequireFollowAsync(request.Id); if (request.Enabled && !follow.LiveMonitorEnabled) throw new InvalidOperationException("请先开启该博主的直播监测"); if (request.Enabled) { var readinessError = await _emailNotifications.GetReadinessErrorAsync(); if (!string.IsNullOrWhiteSpace(readinessError)) throw new InvalidOperationException(readinessError); } follow.LiveEmailNotificationEnabled = request.Enabled; follow.LastLiveNotificationError = null; if (!request.Enabled) { follow.LastLiveNotificationKey = null; follow.LastLiveNotificationAttemptAt = null; } await UpdateFollowNotificationColumnsAsync(follow); if (request.Enabled && follow.LiveStatus == DouyinLiveStatusState.Live) { await HandleLiveNotificationAsync(follow, DouyinLiveStatusState.Unknown, CancellationToken.None); follow = await RequireFollowAsync(follow.Id); } return ToDto(follow); } public async Task RefreshAsync( string id, CancellationToken cancellationToken = default) { var follow = await RequireFollowAsync(id); if (!follow.LiveMonitorEnabled) throw new InvalidOperationException("请先开启该博主的直播监测"); follow = await ProbeFollowAsync(follow, ignoreRecentCheck: false, cancellationToken); return ToDto(follow); } public async Task> QueryAsync(IEnumerable ids) { var normalized = (ids ?? Array.Empty()) .Where(x => !string.IsNullOrWhiteSpace(x)) .Select(x => x.Trim()) .Distinct(StringComparer.Ordinal) .Take(100) .ToList(); if (normalized.Count == 0) return new List(); var follows = await _db.Queryable() .Where(x => normalized.Contains(x.Id)) .ToListAsync(); return follows.Select(ToDto).ToList(); } public async Task RefreshDueAsync(CancellationToken cancellationToken = default) { var cutoff = DateTime.Now.Subtract(RefreshInterval); var follows = await _db.Queryable() .Where(x => x.LiveMonitorEnabled && (x.LiveCheckedAt == null || x.LiveCheckedAt <= cutoff)) .OrderBy(x => x.LiveCheckedAt) .ToListAsync(); if (follows.Count == 0) return; var myUserIds = follows.Select(x => x.mySelfId).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList(); var cookies = await _db.Queryable() .Where(x => myUserIds.Contains(x.MyUserId)) .ToListAsync(); foreach (var group in follows.GroupBy(x => x.mySelfId, StringComparer.Ordinal)) { cancellationToken.ThrowIfCancellationRequested(); var cookie = SelectCookie(cookies, group.Key); if (cookie == null || cookie.Status != 1 || cookie.StatusCode != 0 || string.IsNullOrWhiteSpace(cookie.Cookies)) { await MarkGroupUnavailableAsync(group, "授权账号无效或未启用,直播状态暂时无法检查"); continue; } if (cookie.LiveCheckCooldownUntil.HasValue && cookie.LiveCheckCooldownUntil > DateTime.Now) { await MarkGroupUnavailableAsync(group, $"直播状态检查已冷却至 {cookie.LiveCheckCooldownUntil.Value.ToLocalTime():MM-dd HH:mm}"); continue; } var items = group.ToList(); for (var index = 0; index < items.Count; index++) { cancellationToken.ThrowIfCancellationRequested(); var result = await ProbeWithCookieAsync(items[index], cookie, cancellationToken); if (result.ShouldStopAccount) break; if (index < items.Count - 1) await Task.Delay(Random.Shared.Next(750, 1501), cancellationToken); } } } public async Task ApplyFollowListStatusesAsync( IEnumerable followings, DouyinCookie cookie) { if (cookie == null || string.IsNullOrWhiteSpace(cookie.MyUserId)) return; var source = (followings ?? Array.Empty()) .Where(x => x != null && !string.IsNullOrWhiteSpace(x.SecUid)) .GroupBy(x => x.SecUid, StringComparer.Ordinal) .ToDictionary(x => x.Key, x => x.First(), StringComparer.Ordinal); if (source.Count == 0) return; var secUids = source.Keys.ToList(); var monitored = await _db.Queryable() .Where(x => x.mySelfId == cookie.MyUserId && x.LiveMonitorEnabled && secUids.Contains(x.SecUid)) .ToListAsync(); foreach (var follow in monitored) { if (!source.TryGetValue(follow.SecUid, out var item)) continue; var probe = DouyinLiveStatusParser.Parse( item.LiveStatus, item.RoomId, item.RoomIdStr, item.RoomData); if (probe.Status == DouyinLiveStatusState.Unknown) continue; var previousStatus = follow.LiveStatus; ApplySuccess(follow, probe, DateTime.Now); await UpdateFollowLiveColumnsAsync(follow); await HandleLiveNotificationAsync(follow, previousStatus, CancellationToken.None); } } private async Task ProbeFollowAsync( DouyinFollowed follow, bool ignoreRecentCheck, CancellationToken cancellationToken) { if (!ignoreRecentCheck && follow.LiveCheckedAt.HasValue && DateTime.Now - follow.LiveCheckedAt.Value < ManualRefreshFloor) return follow; var cookies = await _db.Queryable() .Where(x => x.MyUserId == follow.mySelfId) .ToListAsync(); var cookie = SelectCookie(cookies, follow.mySelfId); if (cookie == null || cookie.Status != 1 || cookie.StatusCode != 0 || string.IsNullOrWhiteSpace(cookie.Cookies)) return await MarkFailedAsync(follow, "授权账号无效或未启用,直播状态暂时无法检查"); if (cookie.LiveCheckCooldownUntil.HasValue && cookie.LiveCheckCooldownUntil > DateTime.Now) return await MarkFailedAsync(follow, $"直播状态检查已冷却至 {cookie.LiveCheckCooldownUntil.Value.ToLocalTime():MM-dd HH:mm}"); await ProbeWithCookieAsync(follow, cookie, cancellationToken); return await RequireFollowAsync(follow.Id); } private async Task ProbeWithCookieAsync( DouyinFollowed follow, DouyinCookie cookie, CancellationToken cancellationToken) { try { var probe = await _client.ProbeAsync(follow.SecUid, cookie.Cookies, cancellationToken); if (probe.Status == DouyinLiveStatusState.Unknown) throw new DouyinLiveStatusRequestException("抖音响应中缺少可识别的直播状态"); var previousStatus = follow.LiveStatus; ApplySuccess(follow, probe, DateTime.Now); await UpdateFollowLiveColumnsAsync(follow); await HandleLiveNotificationAsync(follow, previousStatus, cancellationToken); await ResetAccountHealthAsync(cookie); return new ProbeResult(false); } catch (DouyinLiveStatusRequestException ex) { var message = Limit(ex.Message, 1000); await MarkFailedAsync(follow, message); if (ex.RequiresAccountCooldown) { await ApplyAccountCooldownAsync(cookie, message); Log.Warning("直播状态检查触发账号冷却:Account={Account}, HTTP={StatusCode}", cookie.UserName, ex.StatusCode.HasValue ? (int)ex.StatusCode.Value : null); } else { Log.Warning("直播状态检查失败:Account={Account}, Blogger={Blogger}, Reason={Reason}", cookie.UserName, follow.UperName, message); } return new ProbeResult(ex.RequiresAccountCooldown); } catch (Exception ex) when (ex is not OperationCanceledException) { var message = "直播状态检查失败,请稍后重试"; await MarkFailedAsync(follow, message); Log.Warning(ex, "直播状态检查发生异常:Account={Account}, Blogger={Blogger}", cookie.UserName, follow.UperName); return new ProbeResult(false); } } private async Task ApplyAccountCooldownAsync(DouyinCookie cookie, string error) { cookie.ConsecutiveLiveCheckFailures = Math.Max(0, cookie.ConsecutiveLiveCheckFailures) + 1; var minutes = cookie.ConsecutiveLiveCheckFailures switch { 1 => 15, 2 => 30, 3 => 60, 4 => 120, _ => 360 }; cookie.LiveCheckCooldownUntil = DateTime.Now.AddMinutes(minutes); cookie.LastLiveCheckError = Limit(error, 1000); cookie.LiveCheckHealthUpdatedAt = DateTime.Now; await _db.Updateable(cookie) .UpdateColumns(x => new { x.ConsecutiveLiveCheckFailures, x.LiveCheckCooldownUntil, x.LastLiveCheckError, x.LiveCheckHealthUpdatedAt }) .ExecuteCommandAsync(); } private async Task ResetAccountHealthAsync(DouyinCookie cookie) { if (cookie.ConsecutiveLiveCheckFailures == 0 && !cookie.LiveCheckCooldownUntil.HasValue && string.IsNullOrWhiteSpace(cookie.LastLiveCheckError)) return; cookie.ConsecutiveLiveCheckFailures = 0; cookie.LiveCheckCooldownUntil = null; cookie.LastLiveCheckError = null; cookie.LiveCheckHealthUpdatedAt = DateTime.Now; await _db.Updateable(cookie) .UpdateColumns(x => new { x.ConsecutiveLiveCheckFailures, x.LiveCheckCooldownUntil, x.LastLiveCheckError, x.LiveCheckHealthUpdatedAt }) .ExecuteCommandAsync(); } private async Task MarkGroupUnavailableAsync(IEnumerable follows, string message) { foreach (var follow in follows) await MarkFailedAsync(follow, message, updateCheckedAt: false); } private async Task MarkFailedAsync( DouyinFollowed follow, string message, bool updateCheckedAt = true) { if (updateCheckedAt) follow.LiveCheckedAt = DateTime.Now; follow.LiveCheckError = Limit(message, 1000); await UpdateFollowLiveColumnsAsync(follow); return follow; } private static void ApplySuccess(DouyinFollowed follow, DouyinLiveStatusProbe probe, DateTime now) { follow.LiveStatus = probe.Status; follow.LiveCheckedAt = now; follow.LiveStatusUpdatedAt = now; follow.LiveCheckError = null; if (probe.Status == DouyinLiveStatusState.Live) { follow.LiveRoomId = Limit(probe.RoomId, 100); follow.LiveWebRid = Limit(probe.WebRid, 100); follow.LiveTitle = Limit(probe.Title, 500); follow.LiveStartedAt = probe.StartedAt; } else { follow.LiveRoomId = null; follow.LiveWebRid = null; follow.LiveTitle = null; follow.LiveStartedAt = null; } } private async Task UpdateFollowLiveColumnsAsync(DouyinFollowed follow) { await _db.Updateable(follow) .UpdateColumns(x => new { x.LiveMonitorEnabled, x.LiveStatus, x.LiveRoomId, x.LiveWebRid, x.LiveTitle, x.LiveCheckedAt, x.LiveStatusUpdatedAt, x.LiveStartedAt, x.LiveCheckError, x.LiveEmailNotificationEnabled }) .ExecuteCommandAsync(); } private async Task HandleLiveNotificationAsync( DouyinFollowed follow, DouyinLiveStatusState previousStatus, CancellationToken cancellationToken) { var gate = NotificationGates.GetOrAdd(follow.Id, _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken); try { follow = await RequireFollowAsync(follow.Id); if (follow.LiveStatus != DouyinLiveStatusState.Live) { if (follow.LastLiveNotificationKey != null || follow.LastLiveNotificationError != null) { follow.LastLiveNotificationKey = null; follow.LastLiveNotificationAttemptAt = null; follow.LastLiveNotificationError = null; await UpdateFollowNotificationColumnsAsync(follow); } return; } if (!follow.LiveEmailNotificationEnabled) return; var notificationKey = !string.IsNullOrWhiteSpace(follow.LiveWebRid) ? "web:" + follow.LiveWebRid : !string.IsNullOrWhiteSpace(follow.LiveRoomId) ? "room:" + follow.LiveRoomId : "live"; if (string.Equals(follow.LastLiveNotificationKey, notificationKey, StringComparison.Ordinal)) return; if (previousStatus == DouyinLiveStatusState.Live && follow.LastLiveNotificationAttemptAt.HasValue && DateTime.Now - follow.LastLiveNotificationAttemptAt.Value < TimeSpan.FromMinutes(15)) return; follow.LastLiveNotificationAttemptAt = DateTime.Now; var result = await _emailNotifications.SendLiveStartedAsync(follow, cancellationToken); if (result.Sent) { follow.LastLiveNotificationKey = notificationKey; follow.LastLiveNotifiedAt = DateTime.Now; follow.LastLiveNotificationError = null; } else if (!string.IsNullOrWhiteSpace(result.Error)) { follow.LastLiveNotificationError = Limit(result.Error, 1000); } await UpdateFollowNotificationColumnsAsync(follow); } finally { gate.Release(); } } private async Task UpdateFollowNotificationColumnsAsync(DouyinFollowed follow) { await _db.Updateable(follow) .UpdateColumns(x => new { x.LiveEmailNotificationEnabled, x.LastLiveNotificationKey, x.LastLiveNotificationAttemptAt, x.LastLiveNotifiedAt, x.LastLiveNotificationError }) .ExecuteCommandAsync(); } private async Task RequireFollowAsync(string id) => await _db.Queryable().InSingleAsync(id) ?? throw new KeyNotFoundException("关注博主记录不存在"); private static DouyinCookie SelectCookie(IEnumerable cookies, string myUserId) => cookies .Where(x => string.Equals(x.MyUserId, myUserId, StringComparison.Ordinal)) .OrderByDescending(x => x.Status == 1 && x.StatusCode == 0 && !string.IsNullOrWhiteSpace(x.Cookies)) .FirstOrDefault(); private static FollowLiveStatusDto ToDto(DouyinFollowed follow) { var stale = follow.LiveMonitorEnabled && (!follow.LiveStatusUpdatedAt.HasValue || DateTime.Now - follow.LiveStatusUpdatedAt.Value > StaleAfter || !string.IsNullOrWhiteSpace(follow.LiveCheckError)); var roomUrl = follow.LiveStatus == DouyinLiveStatusState.Live && !string.IsNullOrWhiteSpace(follow.LiveWebRid) ? "https://live.douyin.com/" + Uri.EscapeDataString(follow.LiveWebRid) : null; return new FollowLiveStatusDto { Id = follow.Id, LiveMonitorEnabled = follow.LiveMonitorEnabled, LiveStatus = follow.LiveStatus, LiveRoomId = follow.LiveRoomId, LiveWebRid = follow.LiveWebRid, LiveTitle = follow.LiveTitle, LiveRoomUrl = roomUrl, LiveCheckedAt = follow.LiveCheckedAt, LiveStatusUpdatedAt = follow.LiveStatusUpdatedAt, LiveStartedAt = follow.LiveStartedAt, LiveCheckError = follow.LiveCheckError, LiveStatusStale = stale, LiveEmailNotificationEnabled = follow.LiveEmailNotificationEnabled, LastLiveNotifiedAt = follow.LastLiveNotifiedAt, LastLiveNotificationError = follow.LastLiveNotificationError }; } private static string Limit(string value, int maxLength) { value = value?.Trim(); return value != null && value.Length > maxLength ? value[..maxLength] : value; } private readonly record struct ProbeResult(bool ShouldStopAccount); } }