138 lines
6.1 KiB
C#
138 lines
6.1 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Text.RegularExpressions;
|
||
using dy.net.model.dto;
|
||
using dy.net.model.entity;
|
||
using SqlSugar;
|
||
|
||
namespace dy.net.service
|
||
{
|
||
public sealed class DouyinUserLookupService
|
||
{
|
||
private static readonly TimeSpan CacheLifetime = TimeSpan.FromMinutes(5);
|
||
private static readonly SemaphoreSlim SearchGate = new(1, 1);
|
||
private static readonly ConcurrentDictionary<string, CacheEntry> Cache = new(StringComparer.Ordinal);
|
||
private static readonly Regex LabeledDouyinNo = new(
|
||
@"^(?:抖音号|douyin)\s*[::]\s*([^\s,,;;]+)",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
private static readonly Regex LabeledDouyinNoPrefix = new(
|
||
@"^(?:抖音号|douyin)\s*[::]",
|
||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||
|
||
private readonly ISqlSugarClient _db;
|
||
private readonly DouyinUserSearchClient _searchClient;
|
||
|
||
public DouyinUserLookupService(ISqlSugarClient db, DouyinUserSearchClient searchClient)
|
||
{
|
||
_db = db;
|
||
_searchClient = searchClient;
|
||
}
|
||
|
||
public async Task<DouyinFollowLookupResult> ResolveAsync(
|
||
DouyinFollowLookupRequest request,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (request == null) throw new InvalidOperationException("查询参数不能为空");
|
||
var query = NormalizeDouyinNo(request.DouyinNo);
|
||
if (string.IsNullOrWhiteSpace(request.CookieId)) throw new InvalidOperationException("请选择抖音授权账号");
|
||
|
||
var cookie = await _db.Queryable<DouyinCookie>().InSingleAsync(request.CookieId);
|
||
if (cookie == null) throw new InvalidOperationException("选择的抖音授权账号不存在");
|
||
if (cookie.Status != 1 || cookie.StatusCode != 0 || string.IsNullOrWhiteSpace(cookie.Cookies))
|
||
throw new InvalidOperationException("当前账号 Cookie 无效或未启用,请先更新抖音授权");
|
||
if (string.IsNullOrWhiteSpace(cookie.MyUserId))
|
||
throw new InvalidOperationException("当前账号缺少用户 UID,请先完善抖音授权配置");
|
||
|
||
var cacheKey = cookie.Id + ":" + query.ToUpperInvariant();
|
||
var candidates = TryGetCached(cacheKey);
|
||
if (candidates == null)
|
||
{
|
||
await SearchGate.WaitAsync(cancellationToken);
|
||
try
|
||
{
|
||
candidates = TryGetCached(cacheKey);
|
||
if (candidates == null)
|
||
{
|
||
candidates = await _searchClient.SearchAsync(query, cookie.Cookies, cancellationToken);
|
||
Cache[cacheKey] = new CacheEntry(DateTimeOffset.UtcNow.Add(CacheLifetime), Clone(candidates));
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
SearchGate.Release();
|
||
}
|
||
}
|
||
|
||
if (candidates.Count == 0) throw new InvalidOperationException("未找到对应博主,请检查抖音号或使用手动添加");
|
||
var secUids = candidates.Select(x => x.SecUid).Distinct().ToList();
|
||
var existing = await _db.Queryable<DouyinFollowed>()
|
||
.Where(x => x.mySelfId == cookie.MyUserId && secUids.Contains(x.SecUid))
|
||
.Select(x => x.SecUid)
|
||
.ToListAsync();
|
||
var existingSet = existing.ToHashSet(StringComparer.Ordinal);
|
||
|
||
foreach (var candidate in candidates)
|
||
{
|
||
candidate.ExactMatch = string.Equals(candidate.UniqueId, query, StringComparison.OrdinalIgnoreCase)
|
||
|| string.Equals(candidate.ShortId, query, StringComparison.OrdinalIgnoreCase);
|
||
candidate.AlreadyExists = existingSet.Contains(candidate.SecUid);
|
||
}
|
||
|
||
return new DouyinFollowLookupResult
|
||
{
|
||
Query = query,
|
||
Candidates = candidates
|
||
.OrderByDescending(x => x.ExactMatch)
|
||
.ThenByDescending(x => x.FollowerCount)
|
||
.Take(10)
|
||
.ToList()
|
||
};
|
||
}
|
||
|
||
public static string NormalizeDouyinNo(string input)
|
||
{
|
||
var value = (input ?? string.Empty).Trim();
|
||
var match = LabeledDouyinNo.Match(value);
|
||
if (match.Success)
|
||
value = match.Groups[1].Value;
|
||
else if (LabeledDouyinNoPrefix.IsMatch(value))
|
||
value = string.Empty;
|
||
value = value.Trim().TrimStart('@').Trim();
|
||
if (string.IsNullOrWhiteSpace(value)) throw new InvalidOperationException("请输入抖音号");
|
||
if (value.Length > 64) throw new InvalidOperationException("抖音号长度不能超过 64 个字符");
|
||
if (value.Any(char.IsWhiteSpace) || value.Any(char.IsControl))
|
||
throw new InvalidOperationException("抖音号格式不正确");
|
||
return value;
|
||
}
|
||
|
||
private static List<DouyinFollowCandidate> TryGetCached(string key)
|
||
{
|
||
if (!Cache.TryGetValue(key, out var entry)) return null;
|
||
if (entry.ExpiresAt <= DateTimeOffset.UtcNow)
|
||
{
|
||
Cache.TryRemove(key, out _);
|
||
return null;
|
||
}
|
||
return Clone(entry.Candidates);
|
||
}
|
||
|
||
private static List<DouyinFollowCandidate> Clone(IEnumerable<DouyinFollowCandidate> source) =>
|
||
source.Select(x => new DouyinFollowCandidate
|
||
{
|
||
SecUid = x.SecUid,
|
||
UperId = x.UperId,
|
||
DouyinNo = x.DouyinNo,
|
||
UniqueId = x.UniqueId,
|
||
ShortId = x.ShortId,
|
||
UperName = x.UperName,
|
||
UperAvatar = x.UperAvatar,
|
||
Signature = x.Signature,
|
||
Enterprise = x.Enterprise,
|
||
FollowerCount = x.FollowerCount,
|
||
ExactMatch = x.ExactMatch,
|
||
AlreadyExists = x.AlreadyExists
|
||
}).ToList();
|
||
|
||
private sealed record CacheEntry(DateTimeOffset ExpiresAt, List<DouyinFollowCandidate> Candidates);
|
||
}
|
||
}
|