feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
+137
View File
@@ -0,0 +1,137 @@
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);
}
}