using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using RainOpsMini.Helpers; using RainOpsMini.Helpers.SquadGameLog; using RainOpsMini.Plugins; using SqlSugar; using System; using System.Collections.Generic; using System.Linq; using System.Net; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; namespace RainOpsMini.Services { /// /// 玩家数据维护服务 /// 负责定时更新玩家的游戏时长、昵称等信息 /// public class PlayerMaintenanceService : BackgroundService { private readonly IServiceProvider _serviceProvider; private readonly GameTimeQueryPlugin _gameTimePlugin; /// /// 每次批量更新的最大玩家数量 /// private const int BATCH_SIZE = 150; /// /// 玩家游戏时长更新的冷却时间(6小时) /// private readonly TimeSpan UPDATE_COOLDOWN = TimeSpan.FromHours(6); /// /// 构造函数 /// /// 服务提供者 /// 游戏时长查询插件实例 public PlayerMaintenanceService(IServiceProvider serviceProvider, GameTimeQueryPlugin gameTimePlugin) { _serviceProvider = serviceProvider; _gameTimePlugin = gameTimePlugin; } /// /// 服务执行主逻辑 /// /// 取消令牌 /// Task protected override async Task ExecuteAsync(CancellationToken stoppingToken) { RainOpsLog.Log("[玩家维护服务] 服务已启动。"); // 等待系统完全启动并连接到 RCON await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); while (!stoppingToken.IsCancellationRequested) { try { // 1. 根据在线玩家更新未知昵称(保持此操作,因为它快速且仅涉及本地数据) await UpdateUnknownNicknames(); // 2. 按优先级处理游戏时长更新 await ProcessGameTimeUpdates(stoppingToken); } catch (Exception ex) { RainOpsLog.Log($"[玩家维护服务] 全局错误: {ex.Message}"); } // 每5分钟运行一次以检查优先级,但单个玩家受冷却时间限制 await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } } /// /// 处理游戏时长更新逻辑 /// 优先级: /// 1. 在线玩家(优先检查无时长或时长为0的) /// 2. 近期加入服务器的玩家 /// 3. 玩家库中时长为0的玩家 /// /// 取消令牌 /// Task private async Task ProcessGameTimeUpdates(CancellationToken token) { var candidates = new List(); var processedInThisRun = new HashSet(); // 1. 优先级:在线玩家 // 获取在线玩家列表(完全拷贝一份数据以确保线程安全) var onlinePlayers = Program.RconCache.GetData().Players; if (onlinePlayers != null) { foreach (var p in onlinePlayers) { if (string.IsNullOrEmpty(p.SteamId)) continue; // 检查是否满足更新条件(冷却时间) if (CanUpdate(p.SteamId)) { candidates.Add(p.SteamId); processedInThisRun.Add(p.SteamId); } } } // 2. 优先级:近期加入的玩家(从数据库查询) // 对应要求:检查玩家库中近期加入服务器的玩家 if (candidates.Count < BATCH_SIZE) { using (var db = DbHelper.GetInstance()) { var recentPlayers = db.Queryable() .OrderBy(p => p.LastSeen, OrderByType.Desc) .Take(100) // 查看最近的100名玩家 .Select(p => p.SteamID) .ToList(); foreach (var id in recentPlayers) { if (candidates.Count >= BATCH_SIZE) break; if (!processedInThisRun.Contains(id) && CanUpdate(id)) { candidates.Add(id); processedInThisRun.Add(id); } } } } // 3. 优先级:游戏时长为0的玩家(从数据库查询) // 对应要求:检查玩家库中时长为0的玩家 if (candidates.Count < BATCH_SIZE) { using (var db = DbHelper.GetInstance()) { var zeroTimePlayers = db.Queryable() .Where(p => p.SquadPlaytimeHours == 0) .OrderBy(p => p.LastSeen, OrderByType.Desc) // 在时长为0的玩家中优先处理最近活跃的 .Take(100) .Select(p => p.SteamID) .ToList(); foreach (var id in zeroTimePlayers) { if (candidates.Count >= BATCH_SIZE) break; if (!processedInThisRun.Contains(id) && CanUpdate(id)) { candidates.Add(id); processedInThisRun.Add(id); } } } } if (candidates.Count > 0) { RainOpsLog.Log($"[玩家维护服务] 正在处理 {candidates.Count} 名玩家的游戏时长更新。"); foreach (var steamId in candidates) { if (token.IsCancellationRequested) break; try { // 再次检查冷却时间以防万一 if (!CanUpdate(steamId)) continue; // 调用插件刷新游戏时长 int minutes = await _gameTimePlugin.RefreshPlayerGameTime(steamId); // 更新最近尝试时间缓存,防止短时间内重复尝试(即使失败或结果为0) _recentAttempts[steamId] = DateTime.Now; // 每次请求间隔1秒,避免触发API速率限制 await Task.Delay(1000, token); } catch (Exception ex) { // 检查是否为429错误 (Too Many Requests) if (ex.Message.Contains("429") || ex.Message.Contains("Too Many Requests")) { RainOpsLog.Log($"[玩家维护服务] API请求受限 (429),尝试使用爬虫查询玩家 {steamId}..."); var (hours, nickname) = SteamCrawlerHelper.GetHtmlSquadRunTime(steamId); if (hours > 0) { // 更新插件缓存和时长 _gameTimePlugin.SetPlayerGameTime(steamId, hours * 60, nickname); _recentAttempts[steamId] = DateTime.Now; RainOpsLog.Log($"[玩家维护服务] 爬虫更新成功: {nickname}, 时长: {hours}小时"); } else { RainOpsLog.Log($"[玩家维护服务] 爬虫未查询到Squad时长或玩家未公开资料。"); } } else { RainOpsLog.Log($"[玩家维护服务] 更新玩家 {steamId} 时出错: {ex.Message}"); } } } } } /// /// 内存缓存,用于跟踪尝试记录(即使失败或返回0),以强制执行6小时冷却时间 /// private static Dictionary _recentAttempts = new Dictionary(); /// /// 检查是否可以更新该玩家(基于冷却时间) /// /// 玩家SteamID /// 如果可以更新返回true,否则返回false private bool CanUpdate(string steamId) { if (string.IsNullOrEmpty(steamId)) return false; // 1. 检查本地尝试缓存(重启后清除,但在运行时循环中有效) if (_recentAttempts.TryGetValue(steamId, out var lastAttempt)) { if ((DateTime.Now - lastAttempt) < UPDATE_COOLDOWN) return false; } // 2. 检查插件的成功更新时间 var lastUpdate = _gameTimePlugin.GetLastUpdateTime(steamId); if (lastUpdate.HasValue) { if ((DateTime.Now - lastUpdate.Value) < UPDATE_COOLDOWN) return false; } return true; } /// /// 更新未知昵称 /// 遍历在线玩家,如果数据库中该玩家昵称为Unknown,则更新为当前在线昵称 /// /// Task private async Task UpdateUnknownNicknames() { try { // 获取在线玩家副本(Program.RconCache.GetData()返回的是副本吗?根据规则4,我们需要注意) // 假设 GetData() 返回的是引用,这里我们应该注意。 // 查看 Program.RconCache 实现通常是返回一个新的对象或者我们需要手动处理。 // 此处我们直接使用 GetData().Players,如果是 List 引用,遍历是安全的(只要不在遍历时修改集合)。 // 为了符合规则4 "数据使用的时候需要完全拷贝一份",我们创建一个新列表。 var rconData = Program.RconCache.GetData(); if (rconData?.Players == null) return; var onlinePlayers = new List(rconData.Players); // 创建副本 if (onlinePlayers.Count == 0) return; int updateCount = 0; using (var db = DbHelper.GetInstance()) { foreach (var player in onlinePlayers) { if (string.IsNullOrEmpty(player.SteamId) || string.IsNullOrEmpty(player.Name) || player.Name == "Unknown") continue; // 检查数据库中是否记录该玩家为 "Unknown" var dbPlayer = db.Queryable() .Where(p => p.SteamID == player.SteamId && (p.LastName == "Unknown" || p.LastName == null || p.LastName == "")) .First(); if (dbPlayer != null) { dbPlayer.LastName = player.Name; dbPlayer.LastSeen = DateTime.Now; db.Updateable(dbPlayer).ExecuteCommand(); updateCount++; } } } if (updateCount > 0) { RainOpsLog.Log($"[玩家维护服务] 已从在线玩家中更新了 {updateCount} 个未知昵称。"); } } catch (Exception ex) { RainOpsLog.Log($"[玩家维护服务] 更新昵称时出错: {ex.Message}"); } } } }