feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -0,0 +1,482 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user