Files
douyin/service/DouyinLiveStatusService.cs
T

483 lines
22 KiB
C#

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<string, SemaphoreSlim> 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<FollowLiveStatusDto> 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<FollowLiveStatusDto> 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<FollowLiveStatusDto> 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<List<FollowLiveStatusDto>> QueryAsync(IEnumerable<string> ids)
{
var normalized = (ids ?? Array.Empty<string>())
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => x.Trim())
.Distinct(StringComparer.Ordinal)
.Take(100)
.ToList();
if (normalized.Count == 0) return new List<FollowLiveStatusDto>();
var follows = await _db.Queryable<DouyinFollowed>()
.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<DouyinFollowed>()
.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<DouyinCookie>()
.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<FollowingsItem> followings,
DouyinCookie cookie)
{
if (cookie == null || string.IsNullOrWhiteSpace(cookie.MyUserId)) return;
var source = (followings ?? Array.Empty<FollowingsItem>())
.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<DouyinFollowed>()
.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<DouyinFollowed> 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<DouyinCookie>()
.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<ProbeResult> 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<DouyinFollowed> follows, string message)
{
foreach (var follow in follows)
await MarkFailedAsync(follow, message, updateCheckedAt: false);
}
private async Task<DouyinFollowed> 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<DouyinFollowed> RequireFollowAsync(string id) =>
await _db.Queryable<DouyinFollowed>().InSingleAsync(id)
?? throw new KeyNotFoundException("关注博主记录不存在");
private static DouyinCookie SelectCookie(IEnumerable<DouyinCookie> 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);
}
}