483 lines
22 KiB
C#
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);
|
|
}
|
|
}
|