using ClockSnowFlake; using dy.net.model.dto; using dy.net.model.entity; using dy.net.model.response; using dy.net.utils; using Quartz.Util; using SqlSugar; namespace dy.net.repository { public class DouyinFollowRepository : BaseRepository { // 注入SQLSugar客户端 public DouyinFollowRepository(ISqlSugarClient db) : base(db) { } public async Task> GetDouyinFollowGroup() { // 账号页签必须来自 Cookie 表,而不是关注表。否则一个刚配置、尚未同步过关注列表的 // 账号不会出现在“新增博主”的账号选择器里,也就无法添加第一位非关注博主。 var cookies = await Db.Queryable() .Where(x => !string.IsNullOrEmpty(x.MyUserId)) .OrderBy(x => x.UserName) .ToListAsync(); var counts = await Db.Queryable() .Where(x => !string.IsNullOrEmpty(x.mySelfId)) .GroupBy(x => x.mySelfId) .Select(x => new DouyinFollowGroupDto { Key = x.mySelfId, Total = SqlFunc.AggregateCount(x.Id) }) .ToListAsync(); var countByUser = counts.ToDictionary(x => x.Key, x => x.Total, StringComparer.Ordinal); return cookies .GroupBy(x => x.MyUserId, StringComparer.Ordinal) .Select(group => group .OrderByDescending(x => x.Status == 1 && x.StatusCode == 0) .First()) .Select(cookie => new DouyinFollowGroupDto { Key = cookie.MyUserId, CookieId = cookie.Id, Name = cookie.UserName, Total = countByUser.GetValueOrDefault(cookie.MyUserId), Status = cookie.Status, StatusCode = cookie.StatusCode, StatusMessage = cookie.StatusMsg }) .ToList(); } /// /// 分页查询收藏视频 /// /// /// 分页结果(视频列表和总数) public async Task<(List list, int totalCount)> GetPagedAsync(FollowRequestDto dto) { var where = this.Db.Queryable() .Where(x => x.mySelfId == dto.MySelfId) .WhereIF(!string.IsNullOrWhiteSpace(dto.FollowUserName), x => x.UperName.Contains(dto.FollowUserName) || x.DouyinNo.Contains(dto.FollowUserName)) .WhereIF(dto.UnOpen, x => !x.OpenSync && !x.FullSync) .WhereIF(!dto.UnOpen && dto.OpenSync, x => x.OpenSync) .WhereIF(!dto.UnOpen && dto.FullSync, x => x.OpenSync && x.FullSync); //.WhereIF(!dto.OpenSync, x => !x.OpenSync && !x.FullSync); var totalCount = await where.CountAsync(); var list = await where.OrderByDescending(x => x.OpenSync).OrderByDescending(x => x.LastSyncTime).Skip((dto.PageIndex - 1) * dto.PageSize).Take(dto.PageSize).ToListAsync(); return (list, totalCount); } public async Task BatchInsert(List followeds) { return await Db.Insertable(followeds).ExecuteCommandAsync() > 0; } public async Task BatchUpdate(List followeds) { return await Db.Updateable(followeds).ExecuteCommandAsync() > 0; } public async Task GetBySecUId(string secUid) { return await this.GetFirstAsync(x => x.SecUid == secUid); } public async Task GetBySecUId(string uperId, string myId) { return await this.GetFirstAsync(x => x.UperId == uperId && x.mySelfId == myId); } public async Task Update(DouyinFollowed followed) { return await this.UpdateAsync(followed); } public async Task Insert(DouyinFollowed followed) { return await this.InsertAsync(followed); } public async Task> GetSyncFollows(string userId) { return await this.Db.Queryable() .Where(x => x.OpenSync == true).Where(x => x.mySelfId == userId).Where(x => !string.IsNullOrWhiteSpace(x.SecUid)) .ToListAsync(); } /// /// 同步关注列表(新增名字和签名变更检测) /// /// /// /// public async Task<(int add, int update, bool succ)> Sync(List followInfos, DouyinCookie ck) { // 基础参数校验 if (followInfos == null) followInfos = new List(); if (ck == null || string.IsNullOrWhiteSpace(ck.MyUserId)) { Serilog.Log.Error("同步关注列表失败:当前用户MyUserId为空"); return (0, 0, false); } //处理list里面 Signature 字段 可能存在一些导致SQL无法执行的特殊字符,需要清洗一下 // ========== 新增:清洗 Signature 字段特殊字符 ========== foreach (var item in followInfos) { if (!string.IsNullOrWhiteSpace(item.Signature)) { item.Signature = CleanSignature(item.Signature); } } try { // 2. 开启SqlSugar事务 await Db.Ado.BeginTranAsync(); // 1. 提取当前批次的SecUid集合(去重) HashSet currentSecUids = followInfos.Select(x => x.SecUid).ToHashSet(); if (!currentSecUids.Any()) { Serilog.Log.Debug($"同步关注列表:当前批次无有效数据({ck.UserName}),直接返回成功"); return (0, 0, true); } // 2. 查询当前批次对应的现有记录(仅查需要对比的,减少数据量) List existFollows = await Db.Queryable() .Where(x => x.mySelfId == ck.MyUserId) .Where(x => !x.IsNoFollowed) .Where(x => currentSecUids.Contains(x.SecUid)) // 仅查当前批次的SecUid .ToListAsync() ?? new List(); // 3. 拆分:新增(当前批次有,数据库无) + 更新(当前批次有,数据库也有且字段变化) HashSet existSecUids = existFollows.Select(x => x.SecUid).ToHashSet(); var toAddFollows = followInfos.Where(x => !existSecUids.Contains(x.SecUid)).ToList(); var toUpdateFollows = new List(); // 3.1 筛选需要更新的记录 foreach (var existFollow in existFollows) { var newFollow = followInfos.FirstOrDefault(x => x.SecUid == existFollow.SecUid); if (newFollow == null) continue; // 检查字段是否变更(精确匹配) bool nameChanged = !string.Equals(existFollow.UperName, newFollow.NickName, StringComparison.Ordinal); bool signatureChanged = !string.Equals(existFollow.Signature, newFollow.Signature, StringComparison.Ordinal); bool enterpriseChanged = !string.Equals(existFollow.Enterprise, newFollow.EnterpriseVerifyReason, StringComparison.Ordinal); bool uperAvatarChanged = !string.Equals(existFollow.UperAvatar, newFollow.Avatar?.UrlList?.FirstOrDefault() ?? "", StringComparison.Ordinal); var douyinNo = ResolveDouyinNo(newFollow); bool dyNoChanged = !string.Equals(existFollow.DouyinNo, douyinNo, StringComparison.Ordinal); if (nameChanged || signatureChanged || uperAvatarChanged || enterpriseChanged|| dyNoChanged) { var updateFoll = new DouyinFollowed { Id = existFollow.Id, // 主键用于匹配 mySelfId = ck.MyUserId, SecUid = existFollow.SecUid, UperName = newFollow.NickName, Signature = newFollow.Signature, Enterprise = newFollow.EnterpriseVerifyReason, UperAvatar = newFollow.Avatar?.UrlList?.FirstOrDefault() ?? "", LastSyncTime = DateTime.UtcNow,// 更新同步时间 DouyinNo = douyinNo }; if (string.IsNullOrWhiteSpace(existFollow.SavePath)) { updateFoll.SavePath = DouyinFileNameHelper.SanitizeLinuxFileName(newFollow.NickName, existFollow.UperId, true); } toUpdateFollows.Add(updateFoll); } } // 4. 分批处理新增(单批200条) if (toAddFollows.Any()) { DouyinFollowed mapToDouyinFollowed(FollowingsItem follow) => new() { Id = IdGener.GetLong().ToString(), Enterprise = follow.EnterpriseVerifyReason, LastSyncTime = DateTime.UtcNow, mySelfId = ck.MyUserId, SecUid = follow.SecUid, OpenSync = false, UperAvatar = follow.Avatar?.UrlList?.FirstOrDefault() ?? "", UperName = follow.NickName, Signature = follow.Signature, UperId = follow.UperId, SavePath = DouyinFileNameHelper.SanitizeLinuxFileName(follow.NickName, follow.UperId, true), DouyinNo = ResolveDouyinNo(follow) }; bool batchAddSuccess = await BatchProcessAsync(toAddFollows, 200, async batch => await BatchInsert(batch.Select(mapToDouyinFollowed).ToList())); if (!batchAddSuccess) { Serilog.Log.Error("同步关注列表失败:新增关注分批插入异常"); return (toAddFollows.Count, toUpdateFollows.Count, false); } } // 5. 分批处理更新(适配SQLSugar语法) if (toUpdateFollows.Any()) { bool batchUpdateSuccess = await BatchProcessAsync(toUpdateFollows, 200, async batch => { int affectedRows = await Db.Updateable(batch) .UpdateColumns(x => new { x.UperName, x.Signature, x.LastSyncTime, x.Enterprise, x.UperAvatar,x.SavePath,x.DouyinNo }) .WhereColumns(x => x.Id) // 按主键匹配 .ExecuteCommandAsync(); return affectedRows >= 0; }); if (!batchUpdateSuccess) { Serilog.Log.Error( $"[{ck.UserName}]同步关注列表失败"); return (toAddFollows.Count, toUpdateFollows.Count, false); } } // 6. 提交事务 await Db.Ado.CommitTranAsync(); // 【重要】删除逻辑已移除:增量场景下不能通过批次对比删除,需单独设计取消关注逻辑 //Serilog.Log.Debug($"[{ck.UserName}]关注列表同步完成:新增{toAddFollows.Count}条,更新{toUpdateFollows.Count}条"); return (toAddFollows.Count, toUpdateFollows.Count, true); } catch (Exception ex) { await Db.Ado.RollbackTranAsync(); Serilog.Log.Error(ex, $"[{ck.UserName}]同步关注列表失败:{ex.Message}"); return (0, 0, false); } } private static string ResolveDouyinNo(FollowingsItem follow) => !string.IsNullOrWhiteSpace(follow?.UniqueId) ? follow.UniqueId : follow?.ShortId ?? string.Empty; /// /// 通用分批处理工具方法 /// /// 数据类型 /// 待处理数据 /// 单批大小 /// 单批处理逻辑(返回是否成功) /// 整体处理结果 private async Task BatchProcessAsync(List dataList, int batchSize, Func, Task> processAction) { if (dataList == null || !dataList.Any() || batchSize <= 0) return true; int totalCount = dataList.Count; int batchCount = (int)Math.Ceiling((double)totalCount / batchSize); for (int i = 0; i < batchCount; i++) { var batch = dataList.Skip(i * batchSize).Take(batchSize).ToList(); if (!batch.Any()) continue; bool success = await processAction(batch); if (!success) { Serilog.Log.Debug($"分批处理失败:第{i + 1}批(数据范围:{i * batchSize}-{Math.Min((i + 1) * batchSize - 1, totalCount - 1)})"); return false; } } return true; } /// /// 清洗签名字段,移除会导致SQL执行异常的特殊字符 /// private string CleanSignature(string input,int maxLength=100) { if (string.IsNullOrWhiteSpace(input)) return string.Empty; // 1. 移除所有不可见控制字符(ASCII 0-31、127):空字符、换行、制表符、响铃等 // 如需保留换行/制表符,可放开注释里的判断条件 char[] validChars = input.Where(c => { // if (c == '\n' || c == '\r' || c == '\t') return true; // 保留换行、回车、制表符 return !char.IsControl(c); }).ToArray(); string result = new string(validChars); // 2. 转义SQL单引号(SQL语法标准:单引号用双单引号转义,避免语句截断) result = result.Replace("'", "''"); // 3. 去除首尾空白 result = result.Trim(); // 4. 可选:过滤反斜杠、空字节等其他高危字符(按需开启) result = result.Replace("\\", string.Empty); result = result.Replace("\0", string.Empty); if (result.Length > maxLength) { result = result.Substring(0, maxLength); } return result; } } }