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 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 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().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() .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 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 Clone(IEnumerable 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 Candidates); } }