diff --git a/src/TelegramPanel.Core/Interfaces/IChannelService.cs b/src/TelegramPanel.Core/Interfaces/IChannelService.cs index 479d2c6..62a54d7 100644 --- a/src/TelegramPanel.Core/Interfaces/IChannelService.cs +++ b/src/TelegramPanel.Core/Interfaces/IChannelService.cs @@ -20,7 +20,7 @@ public interface IChannelService /// /// 获取账号当前可见的全部频道(创建者/管理员/普通成员) /// - Task> GetVisibleChannelsAsync(int accountId); + Task> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default); /// /// 创建新频道 @@ -57,6 +57,16 @@ public interface IChannelService /// Task SetForwardingAllowedAsync(int accountId, long channelId, bool allowed); + /// + /// 退出频道或取消订阅。 + /// + Task LeaveChannelAsync(int accountId, long channelId); + + /// + /// 解散频道(需要频道创建者权限)。 + /// + Task DisbandChannelAsync(int accountId, long channelId); + /// /// 导出频道加入链接:公开频道返回 t.me 链接;私密频道导出邀请链接(需要权限)。 /// diff --git a/src/TelegramPanel.Core/Interfaces/IGroupService.cs b/src/TelegramPanel.Core/Interfaces/IGroupService.cs index d952ce5..c0d939d 100644 --- a/src/TelegramPanel.Core/Interfaces/IGroupService.cs +++ b/src/TelegramPanel.Core/Interfaces/IGroupService.cs @@ -12,16 +12,31 @@ public interface IGroupService /// Task> GetOwnedGroupsAsync(int accountId); + /// + /// 创建群组(超级群组) + /// + Task CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null); + /// /// 获取账号当前可见的全部群组(创建者/管理员/普通成员) /// - Task> GetVisibleGroupsAsync(int accountId); + Task> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default); /// /// 获取群组详情 /// Task GetGroupInfoAsync(int accountId, long groupId); + /// + /// 退出群组。 + /// + Task LeaveGroupAsync(int accountId, long groupId); + + /// + /// 解散群组(超级群需要创建者权限;基础群暂不支持)。 + /// + Task DisbandGroupAsync(int accountId, long groupId); + /// /// 导出加入链接:公开群组返回 t.me 链接;否则导出邀请链接。 /// diff --git a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs index cbcff7e..c1542a2 100644 --- a/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs +++ b/src/TelegramPanel.Core/Services/BatchTaskManagementService.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; using TelegramPanel.Data.Entities; using TelegramPanel.Data.Repositories; @@ -9,10 +11,17 @@ namespace TelegramPanel.Core.Services; public class BatchTaskManagementService { private readonly IBatchTaskRepository _batchTaskRepository; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; - public BatchTaskManagementService(IBatchTaskRepository batchTaskRepository) + public BatchTaskManagementService( + IBatchTaskRepository batchTaskRepository, + IConfiguration configuration, + ILogger logger) { _batchTaskRepository = batchTaskRepository; + _configuration = configuration; + _logger = logger; } public async Task GetTaskAsync(int id) @@ -40,6 +49,11 @@ public class BatchTaskManagementService return await _batchTaskRepository.GetRecentTasksAsync(count); } + public async Task TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default) + { + return await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken); + } + public async Task CreateTaskAsync(BatchTask task) { task.CreatedAt = DateTime.UtcNow; @@ -123,12 +137,13 @@ public class BatchTaskManagementService public async Task CompleteTaskAsync(int taskId, bool success = true) { var task = await _batchTaskRepository.GetByIdAsync(taskId); - if (task != null) - { - task.Status = success ? "completed" : "failed"; - task.CompletedAt = DateTime.UtcNow; - await _batchTaskRepository.UpdateAsync(task); - } + if (task == null) + return; + + task.Status = success ? "completed" : "failed"; + task.CompletedAt = DateTime.UtcNow; + await _batchTaskRepository.UpdateAsync(task); + await TrimHistoryTasksIfNeededAsync(); } public async Task DeleteTaskAsync(int id) @@ -139,4 +154,29 @@ public class BatchTaskManagementService await _batchTaskRepository.DeleteAsync(task); } } + + private async Task TrimHistoryTasksIfNeededAsync(CancellationToken cancellationToken = default) + { + var keepCount = GetHistoryRetentionLimit(); + if (keepCount <= 0) + return; + + var deletedCount = await _batchTaskRepository.TrimHistoryTasksAsync(keepCount, cancellationToken); + if (deletedCount > 0) + { + _logger.LogInformation( + "Trimmed {DeletedCount} historical batch tasks, keepCount={KeepCount}", + deletedCount, + keepCount); + } + } + + private int GetHistoryRetentionLimit() + { + var rawValue = _configuration["BatchTasks:HistoryRetentionLimit"]; + if (!int.TryParse(rawValue, out var keepCount) || keepCount < 0) + return 0; + + return Math.Min(keepCount, 5000); + } } diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 783a6bb..15e901c 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -187,6 +187,43 @@ public class ChannelManagementService await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds); } + public async Task> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _accountChannelRepository.GetByAccountAsync(accountId, cancellationToken); + } + + public async Task> GetChannelAccountMembershipsAsync(int channelId, CancellationToken cancellationToken = default) + { + return await _accountChannelRepository.GetByChannelAsync(channelId, cancellationToken); + } + + public async Task RemoveAccountChannelAsync(int channelId, int accountId) + { + var channel = await _channelRepository.GetByIdAsync(channelId); + if (channel == null) + return; + + var hasRemainingRelations = channel.AccountChannels.Any(x => x.AccountId != accountId); + var creatorRemoved = channel.CreatorAccountId == accountId; + + await _accountChannelRepository.DeleteAsync(accountId, channelId); + + if (creatorRemoved) + channel.CreatorAccountId = null; + + if (!hasRemainingRelations && channel.CreatorAccountId == null) + { + await _channelRepository.DeleteAsync(channel); + return; + } + + if (!creatorRemoved) + return; + + channel.SyncedAt = DateTime.UtcNow; + await _channelRepository.UpdateAsync(channel); + } + /// /// 解析频道操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 53de5be..4a2f7f6 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -130,6 +130,43 @@ public class GroupManagementService await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds); } + public async Task> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _accountGroupRepository.GetByAccountAsync(accountId, cancellationToken); + } + + public async Task> GetGroupAccountMembershipsAsync(int groupId, CancellationToken cancellationToken = default) + { + return await _accountGroupRepository.GetByGroupAsync(groupId, cancellationToken); + } + + public async Task RemoveAccountGroupAsync(int groupId, int accountId) + { + var group = await _groupRepository.GetByIdAsync(groupId); + if (group == null) + return; + + var hasRemainingRelations = group.AccountGroups.Any(x => x.AccountId != accountId); + var creatorRemoved = group.CreatorAccountId == accountId; + + await _accountGroupRepository.DeleteAsync(accountId, groupId); + + if (creatorRemoved) + group.CreatorAccountId = null; + + if (!hasRemainingRelations && group.CreatorAccountId == null) + { + await _groupRepository.DeleteAsync(group); + return; + } + + if (!creatorRemoved) + return; + + group.SyncedAt = DateTime.UtcNow; + await _groupRepository.UpdateAsync(group); + } + /// /// 解析群组操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 05a0445..52ad472 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -50,7 +50,12 @@ public class AccountTelegramToolsService var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); cancellationToken.ThrowIfCancellationRequested(); - var users = await client.Users_GetUsers(InputUser.Self); + var users = await ExecuteTelegramRequestAsync( + accountId, + "拉取账号资料", + () => client.Users_GetUsers(InputUser.Self), + cancellationToken, + resetClientOnTimeout: true); cancellationToken.ThrowIfCancellationRequested(); var self = users.OfType().FirstOrDefault(); @@ -1409,10 +1414,22 @@ public class AccountTelegramToolsService try { - await client.ConnectAsync(); + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); cancellationToken.ThrowIfCancellationRequested(); if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - await client.LoginUserIfNeeded(reloginOnFailedResume: false); + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } } catch (Exception ex) { @@ -1433,6 +1450,70 @@ public class AccountTelegramToolsService return client; } + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + private int ResolveApiId(Account account) { if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) @@ -1492,7 +1573,12 @@ public class AccountTelegramToolsService UpdatesBase updates; try { - updates = await client.Channels_CreateChannel(title: title, about: about, broadcast: true); + updates = await ExecuteTelegramRequestAsync( + accountId, + "创建测试频道探测账号状态", + () => client.Channels_CreateChannel(title: title, about: about, broadcast: true), + cancellationToken, + resetClientOnTimeout: true); } catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase)) { @@ -1509,7 +1595,12 @@ public class AccountTelegramToolsService { // 立即删除,避免留下垃圾频道 var input = new InputChannel(channel.id, channel.access_hash); - await client.Channels_DeleteChannel(input); + await ExecuteTelegramRequestAsync( + accountId, + $"删除测试频道({channel.id})", + () => client.Channels_DeleteChannel(input), + cancellationToken, + resetClientOnTimeout: false); } catch (Exception ex) { @@ -1535,6 +1626,12 @@ public class AccountTelegramToolsService { var msg = ex.Message ?? string.Empty; + if (ex is TimeoutException + || msg.Contains("请求超时", StringComparison.OrdinalIgnoreCase) + || msg.Contains("timed out", StringComparison.OrdinalIgnoreCase) + || msg.Contains("timeout", StringComparison.OrdinalIgnoreCase)) + return ("请求超时", msg); + if (msg.Contains("EMAIL_HASH_EXPIRED", StringComparison.OrdinalIgnoreCase)) return ( "邮箱验证码已过期(EMAIL_HASH_EXPIRED)", diff --git a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs index a6f6f05..905b93d 100644 --- a/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/ChannelService.cs @@ -39,7 +39,7 @@ public class ChannelService : IChannelService foreach (var (_, chat) in dialogs.chats) { - if (chat is not Channel channel || !channel.IsActive) + if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel) continue; try @@ -98,7 +98,7 @@ public class ChannelService : IChannelService foreach (var (_, chat) in dialogs.chats) { - if (chat is not Channel channel || !channel.IsActive) + if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel) continue; try @@ -152,16 +152,25 @@ public class ChannelService : IChannelService return channels; } - public async Task> GetVisibleChannelsAsync(int accountId) + public async Task> GetVisibleChannelsAsync(int accountId, CancellationToken cancellationToken = default) { - var client = await GetOrCreateConnectedClientAsync(accountId); + cancellationToken.ThrowIfCancellationRequested(); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); var channels = new List(); - var dialogs = await client.Messages_GetAllDialogs(); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + cancellationToken, + resetClientOnTimeout: true); foreach (var (_, chat) in dialogs.chats) { - if (chat is not Channel channel || !channel.IsActive) + cancellationToken.ThrowIfCancellationRequested(); + + if (chat is not Channel channel || !channel.IsActive || !channel.IsChannel) continue; try @@ -176,7 +185,12 @@ public class ChannelService : IChannelService string? about = null; try { - var fullChannel = await client.Channels_GetFullChannel(channel); + var fullChannel = await ExecuteTelegramRequestAsync( + accountId, + $"拉取频道详情({channel.id})", + () => client.Channels_GetFullChannel(channel), + cancellationToken, + resetClientOnTimeout: false); memberCount = fullChannel.full_chat.ParticipantsCount; about = (fullChannel.full_chat as ChannelFull)?.about; } @@ -725,6 +739,46 @@ public class ChannelService : IChannelService return true; } + public async Task LeaveChannelAsync(int accountId, long channelId) + { + try + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var channel = await GetChannelByIdAsync(client, channelId) + ?? throw new InvalidOperationException($"频道 {channelId} not found"); + + await ExecuteTelegramRequestAsync( + accountId, + $"退出频道({channel.id})", + () => client.LeaveChat(channel.ToInputPeer()), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + public async Task DisbandChannelAsync(int accountId, long channelId) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var channel = await GetChannelByIdAsync(client, channelId) + ?? throw new InvalidOperationException($"频道 {channelId} not found"); + + var input = new InputChannel(channel.id, channel.access_hash); + await ExecuteTelegramRequestAsync( + accountId, + $"解散频道({channel.id})", + () => client.Channels_DeleteChannel(input), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + public async Task ExportJoinLinkAsync(int accountId, long channelId) { var client = await GetOrCreateConnectedClientAsync(accountId); @@ -891,8 +945,10 @@ public class ChannelService : IChannelService .FirstOrDefault(c => candidateSet?.Contains(c.id) ?? c.id == channelId); } - private async Task GetOrCreateConnectedClientAsync(int accountId) + private async Task GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var existing = _clientPool.GetClient(accountId); if (existing?.User != null) return existing; @@ -932,9 +988,22 @@ public class ChannelService : IChannelService try { - await client.ConnectAsync(); + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); + if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - await client.LoginUserIfNeeded(reloginOnFailedResume: false); + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } } catch (Exception ex) { @@ -955,6 +1024,70 @@ public class ChannelService : IChannelService return client; } + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + private int ResolveApiId(TelegramPanel.Data.Entities.Account account) { if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) diff --git a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs index 040feb9..dd2743a 100644 --- a/src/TelegramPanel.Core/Services/Telegram/GroupService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/GroupService.cs @@ -36,15 +36,91 @@ public class GroupService : IGroupService return groups.Where(x => x.IsCreator).ToList(); } - public async Task> GetVisibleGroupsAsync(int accountId) + public async Task CreateGroupAsync(int accountId, string title, string about, bool isPublic = false, string? username = null) { var client = await GetOrCreateConnectedClientAsync(accountId); + _logger.LogInformation("Creating group '{Title}' for account {AccountId}", title, accountId); + + if (isPublic) + { + username = username?.Trim().TrimStart('@'); + if (string.IsNullOrWhiteSpace(username)) + throw new InvalidOperationException("公开群组需要设置用户名"); + } + + UpdatesBase updates; + try + { + updates = await client.Channels_CreateChannel( + title: title, + about: about, + broadcast: false, + megagroup: true + ); + } + catch (RpcException ex) when (ex.Code == 420 && string.Equals(ex.Message, "FROZEN_METHOD_INVALID", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + "Telegram 返回 FROZEN_METHOD_INVALID:当前 ApiId/ApiHash 或账号被 Telegram 限制调用创建群组接口。" + + "建议:在【系统设置】更换全局 ApiId/ApiHash(推荐使用你自己在 my.telegram.org 申请的)," + + "并重新导入/重新登录生成 session 后再试。", + ex); + } + + var group = updates.Chats.Values.OfType().FirstOrDefault(c => !c.IsChannel) + ?? throw new InvalidOperationException("群组创建失败"); + + if (isPublic && !string.IsNullOrWhiteSpace(username)) + { + var available = await client.Channels_CheckUsername(group, username); + if (!available) + throw new InvalidOperationException($"用户名 '{username}' 不可用"); + + try + { + await client.Channels_UpdateUsername(group, username); + } + catch (RpcException ex) when (ex.Message.Contains("USERNAME_NOT_MODIFIED", StringComparison.OrdinalIgnoreCase)) + { + // 视为成功 + } + } + + return new GroupInfo + { + TelegramId = group.id, + AccessHash = group.access_hash, + Title = group.title, + Username = isPublic ? username : group.MainUsername, + MemberCount = 0, + About = about, + CreatorAccountId = accountId, + IsCreator = true, + IsAdmin = true, + CreatedAt = group.date, + SyncedAt = DateTime.UtcNow + }; + } + + public async Task> GetVisibleGroupsAsync(int accountId, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + var groups = new List(); - var dialogs = await client.Messages_GetAllDialogs(); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + cancellationToken, + resetClientOnTimeout: true); foreach (var (_, chat) in dialogs.chats) { + cancellationToken.ThrowIfCancellationRequested(); + // 基础群组(Chat 类型,非 Channel) if (chat is Chat basicChat && basicChat.IsActive) { @@ -56,7 +132,12 @@ public class GroupService : IGroupService try { - var fullChat = await client.Messages_GetFullChat(basicChat.id); + var fullChat = await ExecuteTelegramRequestAsync( + accountId, + $"拉取基础群详情({basicChat.id})", + () => client.Messages_GetFullChat(basicChat.id), + cancellationToken, + resetClientOnTimeout: false); if (fullChat.full_chat is ChatFull cf) { about = ReadString(cf, about, "about", "About"); @@ -117,7 +198,12 @@ public class GroupService : IGroupService string? about = null; try { - var fullChannel = await client.Channels_GetFullChannel(channel); + var fullChannel = await ExecuteTelegramRequestAsync( + accountId, + $"拉取超级群详情({channel.id})", + () => client.Channels_GetFullChannel(channel), + cancellationToken, + resetClientOnTimeout: false); memberCount = fullChannel.full_chat.ParticipantsCount; about = (fullChannel.full_chat as ChannelFull)?.about; } @@ -330,6 +416,84 @@ public class GroupService : IGroupService return groups.FirstOrDefault(g => g.TelegramId == groupId); } + public async Task LeaveGroupAsync(int accountId, long groupId) + { + try + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + CancellationToken.None, + resetClientOnTimeout: true); + + var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId); + var chat = dialogs.chats.Values.FirstOrDefault(x => + x switch + { + Chat basicChat => basicChat.id == normalizedId, + Channel channel => !channel.IsChannel && channel.id == normalizedId, + _ => false + }); + + if (chat == null) + throw new InvalidOperationException($"群组 {groupId} not found"); + + await ExecuteTelegramRequestAsync( + accountId, + $"退出群组({normalizedId})", + () => client.LeaveChat(chat.ToInputPeer()), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + catch (RpcException ex) when (ex.Code == 400 && string.Equals(ex.Message, "USER_NOT_PARTICIPANT", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + public async Task DisbandGroupAsync(int accountId, long groupId) + { + var client = await GetOrCreateConnectedClientAsync(accountId); + var dialogs = await ExecuteTelegramRequestAsync( + accountId, + "拉取频道/群组对话列表", + () => client.Messages_GetAllDialogs(), + CancellationToken.None, + resetClientOnTimeout: true); + + var normalizedId = groupId > 0 ? groupId : Math.Abs(groupId); + var chat = dialogs.chats.Values.FirstOrDefault(x => + x switch + { + Chat basicChat => basicChat.id == normalizedId, + Channel channel => !channel.IsChannel && channel.id == normalizedId, + _ => false + }); + + if (chat == null) + throw new InvalidOperationException($"群组 {groupId} not found"); + + if (chat is Chat) + throw new InvalidOperationException("基础群暂不支持一键解散,请在 Telegram 客户端手动操作或先升级为超级群"); + + if (chat is not Channel megaGroup) + throw new InvalidOperationException("仅支持解散超级群"); + + var input = new InputChannel(megaGroup.id, megaGroup.access_hash); + await ExecuteTelegramRequestAsync( + accountId, + $"解散群组({normalizedId})", + () => client.Channels_DeleteChannel(input), + CancellationToken.None, + resetClientOnTimeout: false); + + return true; + } + public async Task ExportJoinLinkAsync(int accountId, long groupId) { var client = await GetOrCreateConnectedClientAsync(accountId); @@ -468,8 +632,10 @@ public class GroupService : IGroupService throw new InvalidOperationException($"群组 {groupId} not found"); } - private async Task GetOrCreateConnectedClientAsync(int accountId) + private async Task GetOrCreateConnectedClientAsync(int accountId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + var existing = _clientPool.GetClient(accountId); if (existing?.User != null) return existing; @@ -509,9 +675,22 @@ public class GroupService : IGroupService try { - await client.ConnectAsync(); + await ExecuteTelegramRequestAsync( + accountId, + "连接 Telegram", + () => client.ConnectAsync(), + cancellationToken, + resetClientOnTimeout: true); + if (client.User == null && (client.UserId != 0 || account.UserId != 0)) - await client.LoginUserIfNeeded(reloginOnFailedResume: false); + { + await ExecuteTelegramRequestAsync( + accountId, + "恢复 Telegram 登录状态", + () => client.LoginUserIfNeeded(reloginOnFailedResume: false), + cancellationToken, + resetClientOnTimeout: true); + } } catch (Exception ex) { @@ -532,6 +711,70 @@ public class GroupService : IGroupService return client; } + private TimeSpan GetTelegramRequestTimeout() + { + var seconds = int.TryParse(_configuration["Telegram:RequestTimeoutSeconds"], out var parsedSeconds) + ? parsedSeconds + : 90; + return TimeSpan.FromSeconds(Math.Clamp(seconds, 15, 600)); + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + + private async Task ExecuteTelegramRequestAsync( + int accountId, + string operation, + Func> action, + CancellationToken cancellationToken, + bool resetClientOnTimeout) + { + var timeout = GetTelegramRequestTimeout(); + + try + { + return await action().WaitAsync(timeout, cancellationToken); + } + catch (TimeoutException) + { + _logger.LogWarning( + "Telegram request timed out after {TimeoutSeconds}s for account {AccountId}: {Operation}", + timeout.TotalSeconds, + accountId, + operation); + + if (resetClientOnTimeout) + await _clientPool.RemoveClientAsync(accountId); + + throw new TimeoutException($"Telegram 请求超时:{operation} 超过 {timeout.TotalSeconds:0} 秒,可能是 Session 失效、账号受限、网络异常或代理异常"); + } + } + private int ResolveApiId(TelegramPanel.Data.Entities.Account account) { if (int.TryParse(_configuration["Telegram:ApiId"], out var globalApiId) && globalApiId > 0) diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 56169a7..0a3ab3b 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -75,6 +75,9 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); + entity.HasIndex(e => e.CreatorAccountId); + entity.HasIndex(e => e.GroupId); + entity.HasIndex(e => e.SyncedAt); entity.HasOne(e => e.CreatorAccount) .WithMany(a => a.Channels) @@ -93,6 +96,7 @@ public class AppDbContext : DbContext entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.AccountId, e.ChannelId }).IsUnique(); entity.HasIndex(e => e.ChannelId); + entity.HasIndex(e => new { e.ChannelId, e.IsCreator, e.IsAdmin }); entity.HasOne(e => e.Account) .WithMany(a => a.AccountChannels) @@ -126,6 +130,8 @@ public class AppDbContext : DbContext entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); entity.HasIndex(e => e.CategoryId); + entity.HasIndex(e => e.CreatorAccountId); + entity.HasIndex(e => e.SyncedAt); entity.HasOne(e => e.CreatorAccount) .WithMany(a => a.Groups) @@ -144,6 +150,7 @@ public class AppDbContext : DbContext entity.HasKey(e => e.Id); entity.HasIndex(e => new { e.AccountId, e.GroupId }).IsUnique(); entity.HasIndex(e => e.GroupId); + entity.HasIndex(e => new { e.GroupId, e.IsCreator, e.IsAdmin }); entity.HasOne(e => e.Account) .WithMany(a => a.AccountGroups) diff --git a/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs new file mode 100644 index 0000000..ea487e7 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.Designer.cs @@ -0,0 +1,739 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using TelegramPanel.Data; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260308181608_OptimizeChatListIndexes")] + partial class OptimizeChatListIndexes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.24"); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApiId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SessionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramStatusDetails") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusOk") + .HasColumnType("INTEGER"); + + b.Property("TelegramStatusSummary") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TwoFactorPassword") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AccountCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("AccountId", "ChannelId") + .IsUnique(); + + b.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("AccountId", "GroupId") + .IsUnique(); + + b.HasIndex("GroupId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("BatchTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("LastUpdateId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ChannelStatusOk"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("BotChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("BotChannelCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BotChannelId") + .HasColumnType("INTEGER"); + + b.Property("BotId") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BotChannelId"); + + b.HasIndex("BotId"); + + b.HasIndex("BotId", "BotChannelId") + .IsUnique(); + + b.ToTable("BotChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("GroupId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ChannelGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("GroupCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") + .WithMany("Accounts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountChannels") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel") + .WithMany("AccountChannels") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountGroups") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Group", "Group") + .WithMany("AccountGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category") + .WithMany("Channels") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel") + .WithMany("Members") + .HasForeignKey("BotChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot") + .WithMany("ChannelMembers") + .HasForeignKey("BotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Bot"); + + b.Navigation("BotChannel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Channels") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group") + .WithMany("Channels") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatorAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category") + .WithMany("Groups") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("CreatorAccount"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Navigation("AccountChannels"); + + b.Navigation("AccountGroups"); + + b.Navigation("Channels"); + + b.Navigation("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Navigation("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Navigation("ChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Navigation("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Navigation("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Navigation("Groups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs new file mode 100644 index 0000000..17a9dd6 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260308181608_OptimizeChatListIndexes.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class OptimizeChatListIndexes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_Groups_SyncedAt", + table: "Groups", + column: "SyncedAt"); + + migrationBuilder.CreateIndex( + name: "IX_Channels_SyncedAt", + table: "Channels", + column: "SyncedAt"); + + migrationBuilder.CreateIndex( + name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin", + table: "AccountGroups", + columns: new[] { "GroupId", "IsCreator", "IsAdmin" }); + + migrationBuilder.CreateIndex( + name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin", + table: "AccountChannels", + columns: new[] { "ChannelId", "IsCreator", "IsAdmin" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Groups_SyncedAt", + table: "Groups"); + + migrationBuilder.DropIndex( + name: "IX_Channels_SyncedAt", + table: "Channels"); + + migrationBuilder.DropIndex( + name: "IX_AccountGroups_GroupId_IsCreator_IsAdmin", + table: "AccountGroups"); + + migrationBuilder.DropIndex( + name: "IX_AccountChannels_ChannelId_IsCreator_IsAdmin", + table: "AccountChannels"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 90970e3..9ce8981 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -159,6 +159,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("AccountId", "ChannelId") .IsUnique(); + b.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + b.ToTable("AccountChannels"); }); @@ -190,6 +192,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("AccountId", "GroupId") .IsUnique(); + b.HasIndex("GroupId", "IsCreator", "IsAdmin"); + b.ToTable("AccountGroups"); }); @@ -447,6 +451,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("GroupId"); + b.HasIndex("SyncedAt"); + b.HasIndex("TelegramId") .IsUnique(); @@ -527,6 +533,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("CreatorAccountId"); + b.HasIndex("SyncedAt"); + b.HasIndex("TelegramId") .IsUnique(); diff --git a/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs b/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs index 5013348..7f11b8a 100644 --- a/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountChannelRepository.cs @@ -17,6 +17,32 @@ public class AccountChannelRepository : Repository, IAccountChan return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.ChannelId == channelId); } + public async Task> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.AccountId == accountId) + .Include(x => x.Channel) + .ThenInclude(x => x.Group) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.ChannelId == channelId) + .Include(x => x.Account) + .ThenInclude(x => x.Category) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToListAsync(cancellationToken); + } + public async Task UpsertAsync(AccountChannel link) { var existing = await GetAsync(link.AccountId, link.ChannelId); @@ -35,6 +61,16 @@ public class AccountChannelRepository : Repository, IAccountChan await _context.SaveChangesAsync(); } + public async Task DeleteAsync(int accountId, int channelId) + { + var existing = await GetAsync(accountId, channelId); + if (existing == null) + return; + + _dbSet.Remove(existing); + await _context.SaveChangesAsync(); + } + public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepChannelIds) { var keep = keepChannelIds.ToHashSet(); diff --git a/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs b/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs index 03f10a1..d14d2ad 100644 --- a/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountGroupRepository.cs @@ -17,6 +17,32 @@ public class AccountGroupRepository : Repository, IAccountGroupRep return await _dbSet.FirstOrDefaultAsync(x => x.AccountId == accountId && x.GroupId == groupId); } + public async Task> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.AccountId == accountId) + .Include(x => x.Group) + .ThenInclude(x => x.Category) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToListAsync(cancellationToken); + } + + public async Task> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default) + { + return await _dbSet + .AsNoTracking() + .Where(x => x.GroupId == groupId) + .Include(x => x.Account) + .ThenInclude(x => x.Category) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToListAsync(cancellationToken); + } + public async Task UpsertAsync(AccountGroup link) { var existing = await GetAsync(link.AccountId, link.GroupId); @@ -35,6 +61,16 @@ public class AccountGroupRepository : Repository, IAccountGroupRep await _context.SaveChangesAsync(); } + public async Task DeleteAsync(int accountId, int groupId) + { + var existing = await GetAsync(accountId, groupId); + if (existing == null) + return; + + _dbSet.Remove(existing); + await _context.SaveChangesAsync(); + } + public async Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepGroupIds) { var keep = keepGroupIds.ToHashSet(); diff --git a/src/TelegramPanel.Data/Repositories/AccountRepository.cs b/src/TelegramPanel.Data/Repositories/AccountRepository.cs index 9efff0f..30774f7 100644 --- a/src/TelegramPanel.Data/Repositories/AccountRepository.cs +++ b/src/TelegramPanel.Data/Repositories/AccountRepository.cs @@ -205,13 +205,17 @@ public class AccountRepository : Repository, IAccountRepository var linkedChannelPairs = await _context.Set() .AsNoTracking() + .Join( + _context.Set().AsNoTracking().Where(x => x.IsBroadcast), + link => link.ChannelId, + channel => channel.Id, + (link, channel) => new { link.AccountId, link.ChannelId }) .Where(x => accountIds.Contains(x.AccountId)) - .Select(x => new { x.AccountId, x.ChannelId }) .ToListAsync(cancellationToken); var createdChannelPairs = await _context.Set() .AsNoTracking() - .Where(x => x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value)) + .Where(x => x.IsBroadcast && x.CreatorAccountId.HasValue && accountIds.Contains(x.CreatorAccountId.Value)) .Select(x => new { AccountId = x.CreatorAccountId!.Value, ChannelId = x.Id }) .ToListAsync(cancellationToken); diff --git a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs index 0438d58..e33169c 100644 --- a/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BatchTaskRepository.cs @@ -35,4 +35,24 @@ public class BatchTaskRepository : Repository, IBatchTaskRepository .Take(count) .ToListAsync(); } + + public async Task TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default) + { + if (keepCount <= 0) + return 0; + + var staleTasks = await _dbSet + .Where(t => t.Status == "completed" || t.Status == "failed") + .OrderByDescending(t => t.CreatedAt) + .ThenByDescending(t => t.Id) + .Skip(keepCount) + .ToListAsync(cancellationToken); + + if (staleTasks.Count == 0) + return 0; + + _dbSet.RemoveRange(staleTasks); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + return staleTasks.Count; + } } diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index 51023eb..97dd136 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -12,16 +12,13 @@ public class ChannelRepository : Repository, IChannelRepository { } - private IQueryable BuildForViewQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search) + private IQueryable BuildFilterQuery(int accountId, int? groupId, string? filterType, string? membershipRole, string? search) { var query = _dbSet .AsNoTracking() - .Include(c => c.CreatorAccount) - .Include(c => c.Group) - .Include(c => c.AccountChannels) - .AsSplitQuery() .AsQueryable(); + query = query.Where(c => c.IsBroadcast); query = query.Where(c => c.CreatorAccountId != null || c.AccountChannels.Any()); membershipRole = (membershipRole ?? "all").Trim().ToLowerInvariant(); @@ -70,7 +67,25 @@ public class ChannelRepository : Repository, IChannelRepository || (c.Group != null && EF.Functions.Like(c.Group.Name, like))); } - return query.OrderByDescending(c => c.SyncedAt); + return query; + } + + private IQueryable BuildPagedDetailQuery(IReadOnlyCollection ids, int accountId) + { + IQueryable query = _dbSet + .AsNoTracking() + .Where(c => ids.Contains(c.Id)) + .Include(c => c.CreatorAccount) + .Include(c => c.Group); + + if (accountId > 0) + { + query = query + .Include(c => c.AccountChannels.Where(x => x.AccountId == accountId)) + .AsSplitQuery(); + } + + return query; } public override async Task GetByIdAsync(int id) @@ -90,6 +105,7 @@ public class ChannelRepository : Repository, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() + .Where(c => c.IsBroadcast) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -111,7 +127,7 @@ public class ChannelRepository : Repository, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => c.CreatorAccountId != null) + .Where(c => c.IsBroadcast && c.CreatorAccountId != null) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -123,7 +139,7 @@ public class ChannelRepository : Repository, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => c.CreatorAccountId == accountId) + .Where(c => c.IsBroadcast && c.CreatorAccountId == accountId) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -138,7 +154,7 @@ public class ChannelRepository : Repository, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => links.Any(x => x.ChannelId == c.Id)) + .Where(c => c.IsBroadcast && links.Any(x => x.ChannelId == c.Id)) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -150,7 +166,7 @@ public class ChannelRepository : Repository, IChannelRepository .Include(c => c.Group) .Include(c => c.AccountChannels) .AsSplitQuery() - .Where(c => c.GroupId == groupId) + .Where(c => c.IsBroadcast && c.GroupId == groupId) .OrderByDescending(c => c.SyncedAt) .ToListAsync(); } @@ -181,13 +197,34 @@ public class ChannelRepository : Repository, IChannelRepository if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildForViewQuery(accountId, groupId, filterType, membershipRole, search); - var total = await query.CountAsync(cancellationToken); - var items = await query + var filteredQuery = BuildFilterQuery(accountId, groupId, filterType, membershipRole, search); + var total = await filteredQuery.CountAsync(cancellationToken); + + if (total == 0) + return (Array.Empty(), 0); + + var pageIds = await filteredQuery + .OrderByDescending(c => c.SyncedAt) + .ThenByDescending(c => c.Id) .Skip(pageIndex * pageSize) .Take(pageSize) + .Select(c => c.Id) .ToListAsync(cancellationToken); - return (items, total); + if (pageIds.Count == 0) + return (Array.Empty(), total); + + var orderMap = pageIds + .Select((id, index) => new { id, index }) + .ToDictionary(x => x.id, x => x.index); + + var items = await BuildPagedDetailQuery(pageIds, accountId) + .ToListAsync(cancellationToken); + + var orderedItems = items + .OrderBy(c => orderMap[c.Id]) + .ToList(); + + return (orderedItems, total); } } diff --git a/src/TelegramPanel.Data/Repositories/GroupRepository.cs b/src/TelegramPanel.Data/Repositories/GroupRepository.cs index 45a4606..9149b75 100644 --- a/src/TelegramPanel.Data/Repositories/GroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/GroupRepository.cs @@ -12,14 +12,10 @@ public class GroupRepository : Repository, IGroupRepository { } - private IQueryable BuildForViewQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search) + private IQueryable BuildFilterQuery(int accountId, int? categoryId, string? filterType, string? membershipRole, string? search) { var query = _dbSet .AsNoTracking() - .Include(g => g.CreatorAccount) - .Include(g => g.Category) - .Include(g => g.AccountGroups) - .AsSplitQuery() .AsQueryable(); query = query.Where(g => g.CreatorAccountId != null || g.AccountGroups.Any()); @@ -70,7 +66,25 @@ public class GroupRepository : Repository, IGroupRepository || (g.Category != null && EF.Functions.Like(g.Category.Name, like))); } - return query.OrderByDescending(g => g.SyncedAt); + return query; + } + + private IQueryable BuildPagedDetailQuery(IReadOnlyCollection ids, int accountId) + { + IQueryable query = _dbSet + .AsNoTracking() + .Where(g => ids.Contains(g.Id)) + .Include(g => g.CreatorAccount) + .Include(g => g.Category); + + if (accountId > 0) + { + query = query + .Include(g => g.AccountGroups.Where(x => x.AccountId == accountId)) + .AsSplitQuery(); + } + + return query; } public override async Task GetByIdAsync(int id) @@ -131,13 +145,34 @@ public class GroupRepository : Repository, IGroupRepository if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildForViewQuery(accountId, categoryId, filterType, membershipRole, search); - var total = await query.CountAsync(cancellationToken); - var items = await query + var filteredQuery = BuildFilterQuery(accountId, categoryId, filterType, membershipRole, search); + var total = await filteredQuery.CountAsync(cancellationToken); + + if (total == 0) + return (Array.Empty(), 0); + + var pageIds = await filteredQuery + .OrderByDescending(g => g.SyncedAt) + .ThenByDescending(g => g.Id) .Skip(pageIndex * pageSize) .Take(pageSize) + .Select(g => g.Id) .ToListAsync(cancellationToken); - return (items, total); + if (pageIds.Count == 0) + return (Array.Empty(), total); + + var orderMap = pageIds + .Select((id, index) => new { id, index }) + .ToDictionary(x => x.id, x => x.index); + + var items = await BuildPagedDetailQuery(pageIds, accountId) + .ToListAsync(cancellationToken); + + var orderedItems = items + .OrderBy(g => orderMap[g.Id]) + .ToList(); + + return (orderedItems, total); } } diff --git a/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs index a9cc9a9..d00296e 100644 --- a/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IAccountChannelRepository.cs @@ -8,7 +8,10 @@ namespace TelegramPanel.Data.Repositories; public interface IAccountChannelRepository : IRepository { Task GetAsync(int accountId, int channelId); + Task> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default); + Task> GetByChannelAsync(int channelId, CancellationToken cancellationToken = default); Task UpsertAsync(AccountChannel link); + Task DeleteAsync(int accountId, int channelId); Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepChannelIds); Task GetPreferredAdminAccountIdAsync(int channelId); } diff --git a/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs index 893c4b5..3d3e70c 100644 --- a/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IAccountGroupRepository.cs @@ -8,7 +8,10 @@ namespace TelegramPanel.Data.Repositories; public interface IAccountGroupRepository : IRepository { Task GetAsync(int accountId, int groupId); + Task> GetByAccountAsync(int accountId, CancellationToken cancellationToken = default); + Task> GetByGroupAsync(int groupId, CancellationToken cancellationToken = default); Task UpsertAsync(AccountGroup link); + Task DeleteAsync(int accountId, int groupId); Task DeleteForAccountExceptAsync(int accountId, IReadOnlyCollection keepGroupIds); Task GetPreferredAdminAccountIdAsync(int groupId); } diff --git a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs index 5fdafd1..68c6a5a 100644 --- a/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBatchTaskRepository.cs @@ -10,4 +10,5 @@ public interface IBatchTaskRepository : IRepository Task> GetByStatusAsync(string status); Task> GetRunningTasksAsync(); Task> GetRecentTasksAsync(int count = 20); + Task TrimHistoryTasksAsync(int keepCount, CancellationToken cancellationToken = default); } diff --git a/src/TelegramPanel.Data/Repositories/Repository.cs b/src/TelegramPanel.Data/Repositories/Repository.cs index a64d413..34b93c8 100644 --- a/src/TelegramPanel.Data/Repositories/Repository.cs +++ b/src/TelegramPanel.Data/Repositories/Repository.cs @@ -59,7 +59,7 @@ public class Repository : IRepository where T : class : await _dbSet.CountAsync(predicate); } - private async Task SaveChangesWithSqliteLockRetryAsync() + protected async Task SaveChangesWithSqliteLockRetryAsync(CancellationToken cancellationToken = default) { // SQLite 在云端(容器/卷/后台任务)场景更容易遇到瞬时写锁;这里做有限重试来提升稳定性 const int maxAttempts = 5; @@ -69,7 +69,7 @@ public class Repository : IRepository where T : class { try { - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(cancellationToken); return; } catch (DbUpdateException ex) when (IsSqliteLock(ex)) diff --git a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs index 9e7cf39..bc0c9f7 100644 --- a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs +++ b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs @@ -85,16 +85,97 @@ public sealed class ModuleTaskDefinition public string? CreateRoute { get; set; } /// - /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback<ModuleTaskDraft>)\n /// + /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。 + /// 该组件需要支持参数: + /// - Draft (ModuleTaskDraft) + /// - DraftChanged (EventCallback<ModuleTaskDraft>) + /// 编辑场景下还应支持可选参数: + /// - InitialConfigJson (string?) + /// [JsonPropertyName("editorComponentType")] public string? EditorComponentType { get; set; } + /// + /// 任务中心行为能力声明(编辑/暂停/恢复/重跑等)。 + /// + [JsonPropertyName("taskCenter")] + public ModuleTaskCenterCapabilities TaskCenter { get; set; } = new(); + [JsonPropertyName("order")] public int Order { get; set; } = 0; } +public sealed class ModuleTaskCenterCapabilities +{ + [JsonPropertyName("canPause")] + public bool CanPause { get; set; } + + [JsonPropertyName("canResume")] + public bool CanResume { get; set; } + + [JsonPropertyName("canEdit")] + public bool CanEdit { get; set; } + + [JsonPropertyName("canRerun")] + public bool CanRerun { get; set; } + + /// + /// 编辑时使用的组件类型;为空时回退到 EditorComponentType。 + /// + [JsonPropertyName("editComponentType")] + public string? EditComponentType { get; set; } + + /// + /// 若任务仍在运行,打开编辑器前是否自动先暂停。 + /// + [JsonPropertyName("autoPauseBeforeEdit")] + public bool AutoPauseBeforeEdit { get; set; } +} + public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError); +public sealed class ModuleTaskSnapshot +{ + [JsonPropertyName("taskId")] + public int TaskId { get; set; } + + [JsonPropertyName("taskType")] + public string TaskType { get; set; } = ""; + + [JsonPropertyName("status")] + public string Status { get; set; } = ""; + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("completed")] + public int Completed { get; set; } + + [JsonPropertyName("failed")] + public int Failed { get; set; } + + [JsonPropertyName("config")] + public string? Config { get; set; } +} + +public sealed class ModuleTaskCreateRequest +{ + [JsonPropertyName("taskType")] + public string TaskType { get; set; } = ""; + + [JsonPropertyName("total")] + public int Total { get; set; } + + [JsonPropertyName("config")] + public string? Config { get; set; } +} + +public interface IModuleTaskRerunBuilder +{ + string TaskType { get; } + ModuleTaskCreateRequest Build(ModuleTaskSnapshot task); +} + public interface IModuleApiProvider { IEnumerable GetApis(ModuleHostContext context); @@ -136,4 +217,3 @@ public interface IModuleTaskExecutionHost Task IsStillRunningAsync(CancellationToken cancellationToken); Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken); } - diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor new file mode 100644 index 0000000..5393251 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountChannelMembershipsDialog.razor @@ -0,0 +1,115 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ChannelManagementService ChannelManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Data.Entities + + + + @if (loading) + { + + } + else + { + + 共 @memberships.Count 个频道 + + @if (memberships.Count == 0) + { + 该账号当前没有已同步的频道记录 + } + else + { + + + 频道名称 + 用户名 + 角色 + 频道分类 + 成员数 + 最后同步 + + + @context.Channel.Title + @FormatUsername(context.Channel.Username) + + @GetRoleLabel(context) + + @(context.Channel.Group?.Name ?? "未分类") + @context.Channel.MemberCount + + + + } + + } + + + 关闭 + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int AccountId { get; set; } + + private bool loading = true; + private List memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await ChannelManagement.GetAccountChannelMembershipsAsync(AccountId)) + .Where(x => x.Channel != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号频道关系失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountChannel membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountChannel membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private void Close() + { + MudDialog.Close(); + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor new file mode 100644 index 0000000..83034aa --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountGroupMembershipsDialog.razor @@ -0,0 +1,115 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject GroupManagementService GroupManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Data.Entities + + + + @if (loading) + { + + } + else + { + + 共 @memberships.Count 个群组 + + @if (memberships.Count == 0) + { + 该账号当前没有已同步的群组记录 + } + else + { + + + 群组名称 + 用户名 + 角色 + 群组分类 + 成员数 + 最后同步 + + + @context.Group.Title + @FormatUsername(context.Group.Username) + + @GetRoleLabel(context) + + @(context.Group.Category?.Name ?? "未分类") + @context.Group.MemberCount + + + + } + + } + + + 关闭 + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int AccountId { get; set; } + + private bool loading = true; + private List memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await GroupManagement.GetAccountGroupMembershipsAsync(AccountId)) + .Where(x => x.Group != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenByDescending(x => x.SyncedAt) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号群组关系失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountGroup membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountGroup membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private void Close() + { + MudDialog.Close(); + } +} + diff --git a/src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor new file mode 100644 index 0000000..0db010b --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/AdminPasswordConfirmDialog.razor @@ -0,0 +1,89 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject TelegramPanel.Web.Services.AdminCredentialStore CredentialStore + + + + + + @Prompt + + + + + @if (!string.IsNullOrWhiteSpace(errorMessage)) + { + @errorMessage + } + + + + 取消 + @ConfirmText + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public string Prompt { get; set; } = "此操作需要输入后台密码确认"; + [Parameter] public string ConfirmText { get; set; } = "确认"; + + private string password = string.Empty; + private string? errorMessage; + private bool submitting; + private bool showPassword; + + private InputType passwordInputType => showPassword ? InputType.Text : InputType.Password; + private string passwordAdornmentIcon => showPassword ? Icons.Material.Filled.VisibilityOff : Icons.Material.Filled.Visibility; + + private void TogglePasswordVisibility() + { + showPassword = !showPassword; + } + + private void Cancel() => MudDialog.Cancel(); + + private async Task ConfirmAsync() + { + if (submitting) + return; + + errorMessage = null; + var value = (password ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + errorMessage = "请输入后台密码"; + return; + } + + submitting = true; + try + { + var ok = await CredentialStore.ValidateAsync(CredentialStore.Username, value); + if (!ok) + { + errorMessage = "后台密码错误"; + return; + } + + MudDialog.Close(DialogResult.Ok(true)); + } + catch (Exception ex) + { + errorMessage = $"校验失败:{ex.Message}"; + } + finally + { + submitting = false; + } + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor index 3559018..ad366dc 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelDetailsDialog.razor @@ -4,6 +4,7 @@ @inject IChannelService ChannelService @inject ISnackbar Snackbar @inject IDialogService DialogService +@using TelegramPanel.Core.Utils @using TelegramPanel.Data.Entities @@ -85,6 +86,52 @@ + + 本系统账号 + + + 查看完整列表 + + + 刷新 + + + + @if (systemAccountsLoading) + { + + } + else if (systemAccounts.Count == 0) + { + 暂无本系统账号关联 + } + else + { + + + 手机号 + 昵称 + 用户名 + 状态 + 角色 + + + @FormatPhone(context.Account?.Phone) + @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname) + @FormatUsername(context.Account?.Username) + + @GetAccountStatusLabel(context.Account) + + + @GetMembershipLabel(context) + + + + } + + + 踢出成员 输入 @@用户名 执行踢出;“非永久”会使用短时间封禁达到踢出效果,随后自动解除,方便对方重新加入。 @@ -130,6 +177,8 @@ private bool adminsLoading; private List admins = new(); + private bool systemAccountsLoading; + private List systemAccounts = new(); private string kickUsername = ""; private bool kickPermanent; @@ -139,6 +188,7 @@ { await LoadChannel(); await LoadAdmins(); + await LoadSystemAccounts(); } private async Task LoadChannel() @@ -202,6 +252,37 @@ } } + private async Task LoadSystemAccounts() + { + systemAccountsLoading = true; + try + { + systemAccounts = (await ChannelManagement.GetChannelAccountMembershipsAsync(ChannelDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + systemAccounts.Clear(); + Snackbar.Add($"加载本系统账号失败:{ex.Message}", Severity.Error); + } + finally + { + systemAccountsLoading = false; + } + } + + private Task ShowSystemAccounts() + { + var parameters = new DialogParameters { ["ChannelDbId"] = ChannelDbId }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show("本系统账号", parameters, options); + return Task.CompletedTask; + } + private async Task KickUser() { if (channel == null) @@ -243,9 +324,48 @@ { await LoadChannel(); await LoadAdmins(); + await LoadSystemAccounts(); } } + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetMembershipLabel(AccountChannel membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetMembershipColor(AccountChannel membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + private void Close() { MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor new file mode 100644 index 0000000..0771ef6 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/ChannelSystemAccountsDialog.razor @@ -0,0 +1,134 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ChannelManagementService ChannelManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Core.Utils +@using TelegramPanel.Data.Entities + + + + @if (loading) + { + + } + else + { + + 共 @memberships.Count 个本系统账号 + + @if (memberships.Count == 0) + { + 该频道当前没有已同步的本系统账号关联 + } + else + { + + + 手机号 + 昵称 + 用户名 + 账号分类 + 状态 + 角色 + 最后同步 + + + @FormatPhone(context.Account?.Phone) + @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname) + @FormatUsername(context.Account?.Username) + @(context.Account?.Category?.Name ?? "未分类") + + @GetAccountStatusLabel(context.Account) + + + @GetRoleLabel(context) + + + + + } + + } + + + 关闭 + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int ChannelDbId { get; set; } + + private bool loading = true; + private List memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await ChannelManagement.GetChannelAccountMembershipsAsync(ChannelDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载频道关联账号失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountChannel membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountChannel membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor index 3d1e684..23f407d 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -204,11 +204,7 @@ if (!Contributions.TaskTypeToDefinition.TryGetValue(opt.TaskType, out var reg)) return; - var typeName = opt.EditorComponentType.Trim(); - var moduleTypeName = NormalizeModuleTypeName(typeName); - editorComponentType = - Type.GetType(typeName, throwOnError: false, ignoreCase: false) - ?? reg.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); + editorComponentType = ModuleTaskEditorComponentResolver.ResolveCreateEditor(reg); if (editorComponentType == null) return; @@ -224,21 +220,6 @@ { editorDraft = draft; } - - private static string NormalizeModuleTypeName(string typeName) - { - typeName = (typeName ?? "").Trim(); - if (typeName.Length == 0) - return typeName; - - // 兼容 AssemblyQualifiedName:Assembly.GetType 只接受纯 FullName - var comma = typeName.IndexOf(',', StringComparison.Ordinal); - if (comma > 0) - return typeName.Substring(0, comma).Trim(); - - return typeName; - } - private async Task Create() { if (creating) diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor new file mode 100644 index 0000000..96366af --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/EditModuleTaskDialog.razor @@ -0,0 +1,200 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject BatchTaskManagementService TaskManagement +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components +@using TelegramPanel.Data.Entities +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules +@using TelegramPanel.Web.Services +@using System.Reflection + + + + @if (loading) + { + + + 正在加载任务配置... + + } + else if (!string.IsNullOrWhiteSpace(loadError)) + { + + @loadError + + } + else if (editorComponentType == null) + { + + 该任务未提供可编辑组件。 + + } + else + { + + 编辑后会更新当前任务配置。若该任务已完成或失败,可继续使用“重新运行”创建新任务执行。 + + + + + @if (!string.IsNullOrWhiteSpace(draft.ValidationError)) + { + @draft.ValidationError + } + } + + + 关闭 + + @if (saving) + { + + 保存中... + } + else + { + 保存配置 + } + + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int TaskId { get; set; } + + private BatchTask? task; + private RegisteredTaskDefinition? taskDefinition; + private Type? editorComponentType; + private Dictionary editorParameters = new(); + private ModuleTaskDraft draft = new(0, null, false, null); + private bool loading = true; + private bool saving; + private string? loadError; + + private bool CanSave => + task != null + && taskDefinition != null + && taskDefinition.Definition.TaskCenter.CanEdit + && editorComponentType != null + && !string.Equals(GetDisplayStatus(task), "running", StringComparison.OrdinalIgnoreCase) + && draft.CanSubmit + && !string.IsNullOrWhiteSpace(draft.Config); + + protected override async Task OnInitializedAsync() + { + try + { + if (TaskId <= 0) + { + loadError = "任务不存在或参数无效。"; + return; + } + + task = await TaskManagement.GetTaskAsync(TaskId); + if (task == null) + { + loadError = "任务不存在或已被删除。"; + return; + } + + if (!Contributions.TaskTypeToDefinition.TryGetValue((task.TaskType ?? string.Empty).Trim(), out var reg)) + { + loadError = "未找到该任务类型的定义,无法编辑。"; + return; + } + + taskDefinition = reg; + if (!reg.Definition.TaskCenter.CanEdit) + { + loadError = "该任务当前不支持编辑。"; + return; + } + + if (string.Equals(GetDisplayStatus(task), "running", StringComparison.OrdinalIgnoreCase)) + { + loadError = "该任务正在执行中,请先暂停后再编辑。"; + return; + } + + editorComponentType = ModuleTaskEditorComponentResolver.ResolveEditEditor(reg); + if (editorComponentType == null) + return; + + editorParameters = BuildEditorParameters(editorComponentType, task); + } + finally + { + loading = false; + } + } + + private Dictionary BuildEditorParameters(Type componentType, BatchTask currentTask) + { + var parameters = new Dictionary + { + ["Draft"] = draft, + ["DraftChanged"] = EventCallback.Factory.Create(this, OnDraftChanged) + }; + + AddOptionalParameter(parameters, componentType, "InitialConfigJson", currentTask.Config); + AddOptionalParameter(parameters, componentType, "TaskId", currentTask.Id); + return parameters; + } + + private static void AddOptionalParameter(Dictionary parameters, Type componentType, string parameterName, object? value) + { + var property = componentType.GetProperty(parameterName, BindingFlags.Public | BindingFlags.Instance); + if (property == null) + return; + + parameters[parameterName] = value; + } + + private static string GetDisplayStatus(BatchTask task) + { + return task.CompletedAt.HasValue + ? "completed" + : (task.Status ?? string.Empty).Trim().ToLowerInvariant(); + } + + private void OnDraftChanged(ModuleTaskDraft value) + { + draft = value; + } + + private async Task Save() + { + if (!CanSave || task == null || saving) + return; + + bool? confirm = await DialogService.ShowMessageBox( + "确认保存", + $"将更新任务 #{task.Id} 的配置,是否继续?", + yesText: "保存", cancelText: "取消"); + + if (confirm != true) + return; + + saving = true; + try + { + var config = draft.Config?.Trim(); + await TaskManagement.UpdateTaskDraftAsync(task.Id, draft.Total < 0 ? 0 : draft.Total, config); + Snackbar.Add($"任务 #{task.Id} 配置已更新", Severity.Success); + MudDialog.Close(DialogResult.Ok(task.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"保存失败:{ex.Message}", Severity.Error); + } + finally + { + saving = false; + } + } + + private void Cancel() => MudDialog.Close(); +} diff --git a/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor index 92b3060..5d4fe97 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/GroupDetailsDialog.razor @@ -3,7 +3,9 @@ @inject AccountManagementService AccountManagement @inject TelegramPanel.Core.Interfaces.IGroupService GroupService @inject ISnackbar Snackbar +@inject IDialogService DialogService @inject IJSRuntime JS +@using TelegramPanel.Core.Utils @using TelegramPanel.Data.Entities @using TelegramPanel.Core.Models @@ -83,6 +85,52 @@ } + + + + + 本系统账号 + + + 查看完整列表 + + + 刷新 + + + + @if (systemAccountsLoading) + { + + } + else if (systemAccounts.Count == 0) + { + 暂无本系统账号关联 + } + else + { + + + 手机号 + 昵称 + 用户名 + 状态 + 角色 + + + @FormatPhone(context.Account?.Phone) + @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname) + @FormatUsername(context.Account?.Username) + + @GetAccountStatusLabel(context.Account) + + + @GetMembershipLabel(context) + + + + } } @@ -107,11 +155,14 @@ private bool adminsLoading; private List admins = new(); + private bool systemAccountsLoading; + private List systemAccounts = new(); protected override async Task OnInitializedAsync() { await LoadGroup(); await LoadAdmins(); + await LoadSystemAccounts(); } private async Task LoadGroup() @@ -175,6 +226,37 @@ } } + private async Task LoadSystemAccounts() + { + systemAccountsLoading = true; + try + { + systemAccounts = (await GroupManagement.GetGroupAccountMembershipsAsync(GroupDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + systemAccounts.Clear(); + Snackbar.Add($"加载本系统账号失败:{ex.Message}", Severity.Error); + } + finally + { + systemAccountsLoading = false; + } + } + + private Task ShowSystemAccounts() + { + var parameters = new DialogParameters { ["GroupDbId"] = GroupDbId }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show("本系统账号", parameters, options); + return Task.CompletedTask; + } + private async Task CopyLink() { if (group == null) @@ -198,6 +280,44 @@ } } + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetMembershipLabel(AccountGroup membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetMembershipColor(AccountGroup membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + private void Close() { MudDialog.Close(); diff --git a/src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor new file mode 100644 index 0000000..92bfc04 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/GroupSystemAccountsDialog.razor @@ -0,0 +1,134 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject GroupManagementService GroupManagement +@inject ISnackbar Snackbar +@using TelegramPanel.Core.Utils +@using TelegramPanel.Data.Entities + + + + @if (loading) + { + + } + else + { + + 共 @memberships.Count 个本系统账号 + + @if (memberships.Count == 0) + { + 该群组当前没有已同步的本系统账号关联 + } + else + { + + + 手机号 + 昵称 + 用户名 + 账号分类 + 状态 + 角色 + 最后同步 + + + @FormatPhone(context.Account?.Phone) + @(string.IsNullOrWhiteSpace(context.Account?.Nickname) ? "-" : context.Account!.Nickname) + @FormatUsername(context.Account?.Username) + @(context.Account?.Category?.Name ?? "未分类") + + @GetAccountStatusLabel(context.Account) + + + @GetRoleLabel(context) + + + + + } + + } + + + 关闭 + + + +@code { + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public int GroupDbId { get; set; } + + private bool loading = true; + private List memberships = new(); + + protected override async Task OnInitializedAsync() + { + await LoadAsync(); + } + + private async Task LoadAsync() + { + loading = true; + try + { + memberships = (await GroupManagement.GetGroupAccountMembershipsAsync(GroupDbId)) + .Where(x => x.Account != null) + .OrderByDescending(x => x.IsCreator) + .ThenByDescending(x => x.IsAdmin) + .ThenBy(x => x.AccountId) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载群组关联账号失败:{ex.Message}", Severity.Error); + memberships.Clear(); + } + finally + { + loading = false; + } + } + + private static string FormatPhone(string? phone) + { + return string.IsNullOrWhiteSpace(phone) ? "-" : PhoneNumberFormatter.FormatWithCountryCode(phone); + } + + private static string FormatUsername(string? username) + { + return string.IsNullOrWhiteSpace(username) ? "-" : "@" + username.Trim(); + } + + private static string GetRoleLabel(AccountGroup membership) + { + if (membership.IsCreator) + return "创建者"; + if (membership.IsAdmin) + return "管理员"; + return "成员"; + } + + private static Color GetRoleColor(AccountGroup membership) + { + if (membership.IsCreator) + return Color.Success; + if (membership.IsAdmin) + return Color.Info; + return Color.Default; + } + + private static string GetAccountStatusLabel(Account? account) + { + return account?.IsActive == false ? "停用" : "启用"; + } + + private static Color GetAccountStatusColor(Account? account) + { + return account?.IsActive == false ? Color.Default : Color.Success; + } + + private void Close() + { + MudDialog.Close(); + } +} diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index 0cd9be2..9352e23 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -20,6 +20,7 @@ 群组列表 + 创建群组 群组分类 diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 7fdbef7..82baa4c 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -115,6 +115,7 @@ 频道/群组 状态 Telegram 状态 + 注册时间 最后数据同步 操作 @@ -155,6 +156,7 @@ 未检测 } + @@ -168,6 +170,8 @@ 加群/订阅 退群/退订 + 查看加入的频道 + 查看加入的群组 系统通知(验证码) 在线设备 @@ -322,6 +326,28 @@ await ReloadTable(); } + private Task OpenAccountChannels(Account account) + { + var parameters = new DialogParameters + { + ["AccountId"] = account.Id + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + DialogService.Show("加入的频道", parameters, options); + return Task.CompletedTask; + } + + private Task OpenAccountGroups(Account account) + { + var parameters = new DialogParameters + { + ["AccountId"] = account.Id + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + DialogService.Show("加入的群组", parameters, options); + return Task.CompletedTask; + } + private async Task OpenChangeTwoFactorPassword(Account account) { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/Channels.razor b/src/TelegramPanel.Web/Components/Pages/Channels.razor index 320395f..3bf8715 100644 --- a/src/TelegramPanel.Web/Components/Pages/Channels.razor +++ b/src/TelegramPanel.Web/Components/Pages/Channels.razor @@ -76,6 +76,9 @@ 批量导出邀请链接(已选) 批量设置管理员(已选) 批量修改分类(已选) + + 批量退出频道(已选) + 批量解散频道(已选) @if (selectedChannels.Count > 0) @@ -174,8 +177,11 @@ 邀请成员 设置管理员 踢人 + 本系统账号 复制链接 + 退出频道 + 解散频道 删除频道 @@ -195,6 +201,9 @@ @code { + [SupplyParameterFromQuery(Name = "accountId")] + public int? QueryAccountId { get; set; } + private MudTable? _table; private List channels = new(); private List accounts = new(); @@ -207,6 +216,7 @@ private string filterType = "all"; private string filterMembershipRole = "all"; private HashSet selectedChannels = new(); + private int? _lastAppliedQueryAccountId; private CancellationTokenSource? _searchDebounceCts; private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; @@ -232,6 +242,19 @@ await LoadGroups(); } + protected override async Task OnParametersSetAsync() + { + var queryAccountId = QueryAccountId.GetValueOrDefault(); + if (_lastAppliedQueryAccountId == queryAccountId) + return; + + _lastAppliedQueryAccountId = queryAccountId; + filterAccount = queryAccountId > 0 ? queryAccountId : 0; + + if (_table != null) + await ReloadTable(); + } + private async Task LoadAccounts() { try @@ -679,6 +702,14 @@ await ReloadTable(); } + private async Task ShowSystemAccounts(Channel channel) + { + var parameters = new DialogParameters { ["ChannelDbId"] = channel.Id }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show("本系统账号", parameters, options); + await Task.CompletedTask; + } + private async Task OpenBatchSetGroupSelected() { var targets = selectedChannels.ToList(); @@ -735,9 +766,7 @@ { try { - var executeAccountId = await ChannelManagement.ResolveExecuteAccountIdAsync( - channel, - preferredAccountId: filterAccount > 0 ? filterAccount : null); + var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel); if (executeAccountId is not > 0) { Snackbar.Add("无法导出链接:该频道暂无可用执行账号,请先同步对应账号的频道数据", Severity.Warning); @@ -755,6 +784,208 @@ } } + private async Task ResolveChannelExecuteAccountIdAsync(Channel channel) + { + return await ChannelManagement.ResolveExecuteAccountIdAsync( + channel, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + } + + private async Task ResolveChannelDisbandAccountIdAsync(Channel channel) + { + if (filterAccount > 0 && channel.CreatorAccountId == filterAccount) + return filterAccount; + + if (channel.CreatorAccountId is > 0) + return channel.CreatorAccountId.Value; + + return await ResolveChannelExecuteAccountIdAsync(channel); + } + + private async Task ConfirmAdminPasswordAsync(string prompt, string confirmText) + { + var parameters = new DialogParameters + { + ["Prompt"] = prompt, + ["ConfirmText"] = confirmText + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show("后台密码确认", parameters, options); + var result = await dialog.Result; + return result is { Canceled: false }; + } + + private void ShowBatchFailureWarnings(IReadOnlyList failures, string actionName) + { + foreach (var failure in failures.Take(3)) + Snackbar.Add($"{actionName}失败:{failure}", Severity.Warning); + + if (failures.Count > 3) + Snackbar.Add($"另有 {failures.Count - 3} 条{actionName}失败,请重试或逐个处理", Severity.Warning); + } + + private async Task LeaveChannel(Channel channel) + { + bool? result = await DialogService.ShowMessageBox( + "确认退出", + $"确定要让执行账号退出频道 {channel.Title} 吗?若系统内其他账号仍在该频道中,频道记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + try + { + var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel); + if (executeAccountId is not > 0) + { + Snackbar.Add("退出失败:该频道暂无可用执行账号", Severity.Warning); + return; + } + + await ChannelService.LeaveChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.RemoveAccountChannelAsync(channel.Id, executeAccountId.Value); + await ReloadTable(); + Snackbar.Add($"已退出频道:{channel.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"退出失败:{ex.Message}", Severity.Error); + } + } + + private async Task LeaveSelectedChannels() + { + var targets = selectedChannels.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认批量退出", + $"确定要让执行账号退出选中的 {targets.Count} 个频道吗?若系统内其他账号仍在这些频道中,频道记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + var successCount = 0; + var failures = new List(); + try + { + foreach (var channel in targets) + { + try + { + var executeAccountId = await ResolveChannelExecuteAccountIdAsync(channel); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await ChannelService.LeaveChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.RemoveAccountChannelAsync(channel.Id, executeAccountId.Value); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{channel.Title}:{ex.Message}"); + } + } + + selectedChannels.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量退出完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "退出频道"); + } + finally + { + loading = false; + } + } + + private async Task DisbandChannel(Channel channel) + { + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散频道 {channel.Title} 吗?此操作会在 Telegram 侧删除频道,并从本地面板移除记录。", + "确认解散"); + if (!confirmed) + return; + + try + { + var executeAccountId = await ResolveChannelDisbandAccountIdAsync(channel); + if (executeAccountId is not > 0) + { + Snackbar.Add("解散失败:该频道暂无可用执行账号", Severity.Warning); + return; + } + + await ChannelService.DisbandChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.DeleteChannelAsync(channel.Id); + await ReloadTable(); + Snackbar.Add($"已解散频道:{channel.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"解散失败:{ex.Message}", Severity.Error); + } + } + + private async Task DisbandSelectedChannels() + { + var targets = selectedChannels.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何频道", Severity.Info); + return; + } + + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散选中的 {targets.Count} 个频道吗?此操作会在 Telegram 侧删除频道,并从本地面板移除记录。", + "确认批量解散"); + if (!confirmed) + return; + + loading = true; + var successCount = 0; + var failures = new List(); + try + { + foreach (var channel in targets) + { + try + { + var executeAccountId = await ResolveChannelDisbandAccountIdAsync(channel); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await ChannelService.DisbandChannelAsync(executeAccountId.Value, channel.TelegramId); + await ChannelManagement.DeleteChannelAsync(channel.Id); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{channel.Title}:{ex.Message}"); + } + } + + selectedChannels.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量解散完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "解散频道"); + } + finally + { + loading = false; + } + } + private async Task DeleteChannel(int id) { var channel = channels.FirstOrDefault(c => c.Id == id); diff --git a/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor new file mode 100644 index 0000000..e84c1ba --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/GroupCreate.razor @@ -0,0 +1,224 @@ +@page "/groups/create" +@inject IGroupService GroupService +@inject AccountManagementService AccountManagement +@inject TelegramPanel.Core.Services.AccountRiskService RiskService +@inject GroupManagementService GroupManagement +@inject GroupCategoryManagementService GroupCategoryManagement +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@inject IDialogService DialogService +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Components.Dialogs + +创建群组 - Telegram Panel + +创建群组 + + + + + + + -- 请选择账号 -- + @foreach (var account in accounts) + { + @FormatAccountLabel(account) + } + + + + 未分类 + @foreach (var category in categories) + { + @category.Name + } + + + + + + + + + @if (isPublic) + { + + } + + + 当前创建的是 Telegram 超级群组。创建成功后会自动写入本地数据库,并绑定到当前执行账号。 + + + + 取消 + + + @if (creating) + { + + 创建中... + } + else + { + 创建群组 + } + + + + + + +@code { + private List accounts = new(); + private List categories = new(); + private int selectedAccountId; + private int selectedCategoryId; + private string groupTitle = string.Empty; + private string groupAbout = string.Empty; + private bool isPublic; + private string username = string.Empty; + private bool creating; + + protected override async Task OnInitializedAsync() + { + await LoadAccounts(); + await LoadCategories(); + } + + private async Task LoadAccounts() + { + try + { + var accountList = await AccountManagement.GetActiveAccountsAsync(); + accounts = accountList + .Where(a => a.Category?.ExcludeFromOperations != true) + .ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载账号列表失败:{ex.Message}", Severity.Error); + } + } + + private async Task LoadCategories() + { + try + { + var list = await GroupCategoryManagement.GetAllCategoriesAsync(); + categories = list.ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载群组分类失败:{ex.Message}", Severity.Error); + } + } + + private async Task CreateGroup() + { + if (selectedAccountId == 0) + { + Snackbar.Add("请选择账号", Severity.Error); + return; + } + + var title = groupTitle.Trim(); + if (string.IsNullOrWhiteSpace(title)) + { + Snackbar.Add("请输入群组名称", Severity.Error); + return; + } + + var about = string.IsNullOrWhiteSpace(groupAbout) ? string.Empty : groupAbout.Trim(); + var normalizedUsername = NormalizeUsername(username); + if (isPublic && string.IsNullOrWhiteSpace(normalizedUsername)) + { + Snackbar.Add("公开群组需要设置用户名", Severity.Error); + return; + } + + var account = await AccountManagement.GetAccountAsync(selectedAccountId); + if (account != null) + { + var riskCheck = RiskService.CheckLoginDuration(account); + if (riskCheck.IsRisky) + { + var parameters = new DialogParameters + { + ["Message"] = riskCheck.Message, + ["DetailedMessage"] = riskCheck.DetailedMessage, + ["ShowRecommendations"] = true + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium }; + var dialog = DialogService.Show("风控警告", parameters, options); + var result = await dialog.Result; + if (result.Canceled) + return; + } + } + + creating = true; + try + { + var groupInfo = await GroupService.CreateGroupAsync(selectedAccountId, title, about, isPublic, normalizedUsername); + var now = DateTime.UtcNow; + + var group = new Group + { + TelegramId = groupInfo.TelegramId, + AccessHash = groupInfo.AccessHash, + Title = groupInfo.Title, + Username = groupInfo.Username, + MemberCount = Math.Max(groupInfo.MemberCount, 1), + About = groupInfo.About, + CreatorAccountId = selectedAccountId, + CategoryId = selectedCategoryId > 0 ? selectedCategoryId : null, + CreatedAt = groupInfo.CreatedAt ?? now, + SyncedAt = now + }; + + var savedGroup = await GroupManagement.CreateOrUpdateGroupAsync(group); + await GroupManagement.UpsertAccountGroupAsync(selectedAccountId, savedGroup.Id, isCreator: true, isAdmin: true, syncedAtUtc: now); + + Snackbar.Add($"群组 \"{title}\" 创建成功", Severity.Success); + Navigation.NavigateTo("/groups"); + } + catch (Exception ex) + { + Snackbar.Add($"创建失败:{ex.Message}", Severity.Error); + } + finally + { + creating = false; + } + } + + private Task Cancel() + { + Navigation.NavigateTo("/groups"); + return Task.CompletedTask; + } + + private static string? NormalizeUsername(string? value) + { + var normalized = value?.Trim().TrimStart('@'); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + private static string FormatAccountLabel(Account account) + { + var nickname = string.IsNullOrWhiteSpace(account.Nickname) ? null : account.Nickname.Trim(); + var username = string.IsNullOrWhiteSpace(account.Username) ? null : account.Username.Trim(); + + var namePart = nickname ?? account.Phone; + if (!string.IsNullOrWhiteSpace(username)) + return $"{namePart} (@{username})"; + return namePart; + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/Groups.razor b/src/TelegramPanel.Web/Components/Pages/Groups.razor index 9ca5bf3..1dc4a00 100644 --- a/src/TelegramPanel.Web/Components/Pages/Groups.razor +++ b/src/TelegramPanel.Web/Components/Pages/Groups.razor @@ -60,6 +60,15 @@ @SelectionToggleText + + 创建群组 + + + 分类管理 + + 同步当前 @@ -75,6 +84,8 @@ 批量导出加入链接(已选) 批量修改分类(已选) + 批量退出群组(已选) + 批量解散群组(已选) 批量删除(已选) @@ -169,8 +180,11 @@ + 本系统账号 复制链接 + 退出群组 + 解散群组 删除群组 @@ -190,6 +204,9 @@ @code { + [SupplyParameterFromQuery(Name = "accountId")] + public int? QueryAccountId { get; set; } + private MudTable? _table; private List groups = new(); private List accounts = new(); @@ -202,6 +219,7 @@ private string filterType = "all"; private string filterMembershipRole = "all"; private HashSet selectedGroups = new(); + private int? _lastAppliedQueryAccountId; private CancellationTokenSource? _searchDebounceCts; private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll; @@ -227,6 +245,19 @@ await LoadCategories(); } + protected override async Task OnParametersSetAsync() + { + var queryAccountId = QueryAccountId.GetValueOrDefault(); + if (_lastAppliedQueryAccountId == queryAccountId) + return; + + _lastAppliedQueryAccountId = queryAccountId; + filterAccount = queryAccountId > 0 ? queryAccountId : 0; + + if (_table != null) + await ReloadTable(); + } + private async Task LoadAccounts() { try @@ -627,13 +658,19 @@ await Task.CompletedTask; } + private async Task ShowSystemAccounts(Group group) + { + var parameters = new DialogParameters { ["GroupDbId"] = group.Id }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; + DialogService.Show("本系统账号", parameters, options); + await Task.CompletedTask; + } + private async Task CopyLinkAsync(Group group) { try { - var executeAccountId = await GroupManagement.ResolveExecuteAccountIdAsync( - group, - preferredAccountId: filterAccount > 0 ? filterAccount : null); + var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group); if (executeAccountId is not > 0) { Snackbar.Add("无法导出链接:该群组暂无可用执行账号,请先同步对应账号的群组数据", Severity.Warning); @@ -650,6 +687,208 @@ } } + private async Task ResolveGroupExecuteAccountIdAsync(Group group) + { + return await GroupManagement.ResolveExecuteAccountIdAsync( + group, + preferredAccountId: filterAccount > 0 ? filterAccount : null); + } + + private async Task ResolveGroupDisbandAccountIdAsync(Group group) + { + if (filterAccount > 0 && group.CreatorAccountId == filterAccount) + return filterAccount; + + if (group.CreatorAccountId is > 0) + return group.CreatorAccountId.Value; + + return await ResolveGroupExecuteAccountIdAsync(group); + } + + private async Task ConfirmAdminPasswordAsync(string prompt, string confirmText) + { + var parameters = new DialogParameters + { + ["Prompt"] = prompt, + ["ConfirmText"] = confirmText + }; + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; + var dialog = DialogService.Show("后台密码确认", parameters, options); + var result = await dialog.Result; + return result is { Canceled: false }; + } + + private void ShowBatchFailureWarnings(IReadOnlyList failures, string actionName) + { + foreach (var failure in failures.Take(3)) + Snackbar.Add($"{actionName}失败:{failure}", Severity.Warning); + + if (failures.Count > 3) + Snackbar.Add($"另有 {failures.Count - 3} 条{actionName}失败,请重试或逐个处理", Severity.Warning); + } + + private async Task LeaveGroup(Group group) + { + bool? result = await DialogService.ShowMessageBox( + "确认退出", + $"确定要让执行账号退出群组 {group.Title} 吗?若系统内其他账号仍在该群组中,群组记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + try + { + var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group); + if (executeAccountId is not > 0) + { + Snackbar.Add("退出失败:该群组暂无可用执行账号", Severity.Warning); + return; + } + + await GroupService.LeaveGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.RemoveAccountGroupAsync(group.Id, executeAccountId.Value); + await ReloadTable(); + Snackbar.Add($"已退出群组:{group.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"退出失败:{ex.Message}", Severity.Error); + } + } + + private async Task LeaveSelectedGroups() + { + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + bool? result = await DialogService.ShowMessageBox( + "确认批量退出", + $"确定要让执行账号退出选中的 {targets.Count} 个群组吗?若系统内其他账号仍在这些群组中,群组记录可能继续显示。", + yesText: "退出", cancelText: "取消"); + + if (result != true) + return; + + loading = true; + var successCount = 0; + var failures = new List(); + try + { + foreach (var group in targets) + { + try + { + var executeAccountId = await ResolveGroupExecuteAccountIdAsync(group); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await GroupService.LeaveGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.RemoveAccountGroupAsync(group.Id, executeAccountId.Value); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{group.Title}:{ex.Message}"); + } + } + + selectedGroups.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量退出完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "退出群组"); + } + finally + { + loading = false; + } + } + + private async Task DisbandGroup(Group group) + { + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散群组 {group.Title} 吗?此操作会在 Telegram 侧删除群组,并从本地面板移除记录。", + "确认解散"); + if (!confirmed) + return; + + try + { + var executeAccountId = await ResolveGroupDisbandAccountIdAsync(group); + if (executeAccountId is not > 0) + { + Snackbar.Add("解散失败:该群组暂无可用执行账号", Severity.Warning); + return; + } + + await GroupService.DisbandGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.DeleteGroupAsync(group.Id); + await ReloadTable(); + Snackbar.Add($"已解散群组:{group.Title}", Severity.Success); + } + catch (Exception ex) + { + Snackbar.Add($"解散失败:{ex.Message}", Severity.Error); + } + } + + private async Task DisbandSelectedGroups() + { + var targets = selectedGroups.ToList(); + if (targets.Count == 0) + { + Snackbar.Add("未选择任何群组", Severity.Info); + return; + } + + var confirmed = await ConfirmAdminPasswordAsync( + $"确定要解散选中的 {targets.Count} 个群组吗?此操作会在 Telegram 侧删除群组,并从本地面板移除记录。", + "确认批量解散"); + if (!confirmed) + return; + + loading = true; + var successCount = 0; + var failures = new List(); + try + { + foreach (var group in targets) + { + try + { + var executeAccountId = await ResolveGroupDisbandAccountIdAsync(group); + if (executeAccountId is not > 0) + throw new InvalidOperationException("暂无可用执行账号"); + + await GroupService.DisbandGroupAsync(executeAccountId.Value, group.TelegramId); + await GroupManagement.DeleteGroupAsync(group.Id); + successCount++; + } + catch (Exception ex) + { + failures.Add($"{group.Title}:{ex.Message}"); + } + } + + selectedGroups.Clear(); + await ReloadTable(); + Snackbar.Add( + $"批量解散完成:成功 {successCount} 个,失败 {failures.Count} 个", + failures.Count == 0 ? Severity.Success : successCount > 0 ? Severity.Warning : Severity.Error); + ShowBatchFailureWarnings(failures, "解散群组"); + } + finally + { + loading = false; + } + } + private async Task DeleteSelectedGroups() { var targets = selectedGroups.ToList(); diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor index 26ab650..a50e46a 100644 --- a/src/TelegramPanel.Web/Components/Pages/Settings.razor +++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor @@ -7,6 +7,7 @@ @inject IGroupService GroupService @inject ITelegramClientPool TelegramClientPool @inject TelegramPanel.Web.Services.DataSyncService DataSync +@inject BatchTaskManagementService TaskManagement @inject ISnackbar Snackbar @inject IDialogService DialogService @inject IWebHostEnvironment Environment @@ -90,6 +91,8 @@ Min="1000" Max="10000" HelperText="建议2000-5000ms" /> + @if (autoRetry) { @@ -265,6 +268,7 @@ private string apiHash = ""; private int defaultDelay = 2000; private int maxConcurrent = 1; + private int historyRetentionLimit = 0; private bool autoRetry = false; private int maxRetries = 3; private bool autoSync = true; @@ -314,6 +318,7 @@ apiHash = Configuration["Telegram:ApiHash"] ?? ""; defaultDelay = Configuration.GetValue("Telegram:DefaultDelayMs", 2000); maxConcurrent = Configuration.GetValue("BatchTasks:MaxConcurrent", 1); + historyRetentionLimit = Configuration.GetValue("BatchTasks:HistoryRetentionLimit", 0); maxRetries = Configuration.GetValue("Telegram:MaxRetries", 0); autoRetry = maxRetries > 0; if (maxRetries <= 0) maxRetries = 3; @@ -567,6 +572,12 @@ return; } + if (historyRetentionLimit < 0 || historyRetentionLimit > 5000) + { + Snackbar.Add("历史任务保留上限范围应为 0-5000", Severity.Error); + return; + } + try { var root = await LoadOrCreateLocalConfigAsync(); @@ -577,9 +588,19 @@ var batchTasks = EnsureObject(root, "BatchTasks"); batchTasks["MaxConcurrent"] = maxConcurrent; + batchTasks["HistoryRetentionLimit"] = historyRetentionLimit; await SaveLocalConfigAsync(root); - Snackbar.Add("批量操作配置已保存(配置热重载后生效)", Severity.Success); + + var deletedCount = historyRetentionLimit > 0 + ? await TaskManagement.TrimHistoryTasksAsync(historyRetentionLimit) + : 0; + + var message = deletedCount > 0 + ? $"批量操作配置已保存,并已清理 {deletedCount} 条超出上限的历史任务" + : "批量操作配置已保存(配置热重载后生效)"; + + Snackbar.Add(message, Severity.Success); StateHasChanged(); } catch (Exception ex) @@ -639,21 +660,16 @@ } syncing = true; - Snackbar.Add("开始同步频道和群组信息...", Severity.Info); + Snackbar.Add("正在后台创建同步任务...", Severity.Info); try { - var run = await DataSync.RunAllActiveAccountsTrackedAsync("manual", CancellationToken.None); - var summary = run.Summary; - - foreach (var failure in summary.AccountFailures) - Snackbar.Add($"账号 {failure.Phone} 同步失败:{failure.Error}", Severity.Warning); - - Snackbar.Add($"同步完成:{summary.TotalChannelsSynced} 个频道,{summary.TotalGroupsSynced} 个群组。任务 #{run.TaskId} 已记录到任务中心", summary.AccountFailures.Count == 0 ? Severity.Success : Severity.Warning); + var taskId = await DataSync.StartAllActiveAccountsTrackedInBackgroundAsync("manual", CancellationToken.None); + Snackbar.Add($"已启动后台同步任务 #{taskId},请到任务中心查看进度", Severity.Success); } catch (Exception ex) { - Snackbar.Add($"同步失败:{ex.Message}", Severity.Error); + Snackbar.Add($"同步启动失败:{ex.Message}", Severity.Error); } finally { diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs index f8cbc4a..077c040 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -1,6 +1,7 @@ +using MudBlazor; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Modules; -using MudBlazor; +using TelegramPanel.Web.Services; namespace TelegramPanel.Web.Modules.BuiltIn; @@ -23,6 +24,7 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide public void ConfigureServices(IServiceCollection services, ModuleHostContext context) { // 内置任务的执行由宿主 BatchTaskBackgroundService 负责;这里只提供“元数据”用于 UI 展示与创建。 + services.AddSingleton(); } public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) @@ -40,6 +42,15 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide Description = "按账号分类持续向指定目标发送词典内容,支持间隔抖动、随机/队列循环。", Icon = Icons.Material.Filled.Chat, EditorComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "", + TaskCenter = new ModuleTaskCenterCapabilities + { + CanPause = true, + CanResume = true, + CanEdit = true, + CanRerun = true, + EditComponentType = typeof(TelegramPanel.Web.Components.Dialogs.UserChatActiveTaskEditor).AssemblyQualifiedName ?? "", + AutoPauseBeforeEdit = true + }, Order = 120 }; diff --git a/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs b/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs index 5bddacd..3de3e78 100644 --- a/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs +++ b/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs @@ -150,6 +150,9 @@ public sealed class ModuleContributionRegistry if (string.IsNullOrWhiteSpace(t.CreateRoute)) t.CreateRoute = null; t.EditorComponentType = (t.EditorComponentType ?? "").Trim(); if (string.IsNullOrWhiteSpace(t.EditorComponentType)) t.EditorComponentType = null; + t.TaskCenter ??= new ModuleTaskCenterCapabilities(); + t.TaskCenter.EditComponentType = (t.TaskCenter.EditComponentType ?? "").Trim(); + if (string.IsNullOrWhiteSpace(t.TaskCenter.EditComponentType)) t.TaskCenter.EditComponentType = null; return t; } diff --git a/src/TelegramPanel.Web/Services/DataSyncService.cs b/src/TelegramPanel.Web/Services/DataSyncService.cs index a1e8b08..c1eb95f 100644 --- a/src/TelegramPanel.Web/Services/DataSyncService.cs +++ b/src/TelegramPanel.Web/Services/DataSyncService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Interfaces; @@ -23,6 +24,7 @@ public class DataSyncService private readonly IGroupService _groupService; private readonly AccountTelegramToolsService _telegramTools; private readonly BatchTaskManagementService _taskManagement; + private readonly IServiceScopeFactory _scopeFactory; private readonly IConfiguration _configuration; private readonly ILogger _logger; @@ -34,6 +36,7 @@ public class DataSyncService IGroupService groupService, AccountTelegramToolsService telegramTools, BatchTaskManagementService taskManagement, + IServiceScopeFactory scopeFactory, IConfiguration configuration, ILogger logger) { @@ -44,28 +47,78 @@ public class DataSyncService _groupService = groupService; _telegramTools = telegramTools; _taskManagement = taskManagement; + _scopeFactory = scopeFactory; _configuration = configuration; _logger = logger; } public async Task RunAllActiveAccountsTrackedAsync(string trigger, CancellationToken cancellationToken) { - if (!await SyncGate.WaitAsync(0, cancellationToken)) - throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度"); + var task = await CreateTrackedTaskAsync(trigger, cancellationToken); + return await ExecuteTrackedSyncAsync(task.Id, trigger, cancellationToken); + } + + public async Task StartAllActiveAccountsTrackedInBackgroundAsync(string trigger, CancellationToken cancellationToken = default) + { + var task = await CreateTrackedTaskAsync(trigger, cancellationToken); + var taskId = task.Id; + var scopeFactory = _scopeFactory; + var logger = _logger; + + _ = Task.Run(async () => + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var dataSync = scope.ServiceProvider.GetRequiredService(); + await dataSync.ExecuteTrackedSyncAsync(taskId, trigger, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Manual account sync background execution failed unexpectedly for task {TaskId}", taskId); + } + }, CancellationToken.None); + + return taskId; + } + + private async Task CreateTrackedTaskAsync(string trigger, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var accounts = await GetDistinctActiveAccountsAsync(); + + return await _taskManagement.CreateTaskAsync(new BatchTask + { + TaskType = BatchTaskTypes.AccountAutoSync, + Total = accounts.Count, + Config = BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: accounts.Count, + processedAccounts: 0, + failedAccounts: 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty(), + error: null) + }); + } + + private async Task ExecuteTrackedSyncAsync(int taskId, string trigger, CancellationToken cancellationToken) + { + var gateEntered = false; - BatchTask? task = null; try { - var accounts = (await _accountManagement.GetActiveAccountsAsync()) - .GroupBy(x => x.Id) - .Select(x => x.First()) - .ToList(); + gateEntered = await SyncGate.WaitAsync(0, cancellationToken); + if (!gateEntered) + throw new InvalidOperationException("账号数据同步已在运行,请到任务中心查看当前进度"); - task = await _taskManagement.CreateTaskAsync(new BatchTask - { - TaskType = BatchTaskTypes.AccountAutoSync, - Total = accounts.Count, - Config = BuildSyncTaskConfig( + var accounts = await GetDistinctActiveAccountsAsync(); + await _taskManagement.UpdateTaskDraftAsync( + taskId, + accounts.Count, + BuildSyncTaskConfig( trigger: trigger, totalAccounts: accounts.Count, processedAccounts: 0, @@ -73,19 +126,18 @@ public class DataSyncService totalChannelsSynced: 0, totalGroupsSynced: 0, failures: Array.Empty(), - error: null) - }); + error: null)); - await _taskManagement.StartTaskAsync(task.Id); + await _taskManagement.StartTaskAsync(taskId); var summary = await SyncAccountsAsync( accounts, cancellationToken, - progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(task.Id, progress.ProcessedAccounts, progress.FailedAccounts)); + progressCallback: progress => _taskManagement.UpdateTaskProgressAsync(taskId, progress.ProcessedAccounts, progress.FailedAccounts)); - await _taskManagement.UpdateTaskProgressAsync(task.Id, summary.ProcessedAccounts, summary.FailedAccountsCount); + await _taskManagement.UpdateTaskProgressAsync(taskId, summary.ProcessedAccounts, summary.FailedAccountsCount); await _taskManagement.UpdateTaskConfigAsync( - task.Id, + taskId, BuildSyncTaskConfig( trigger: trigger, totalAccounts: summary.TotalAccounts, @@ -95,58 +147,59 @@ public class DataSyncService totalGroupsSynced: summary.TotalGroupsSynced, failures: summary.AccountFailures.Select(ToFailureItem).ToList(), error: null)); - await _taskManagement.CompleteTaskAsync(task.Id, success: true); + await _taskManagement.CompleteTaskAsync(taskId, success: true); - return new TrackedSyncResult(task.Id, summary); + return new TrackedSyncResult(taskId, summary); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { - if (task != null) - { - var snapshot = await _taskManagement.GetTaskAsync(task.Id); - await _taskManagement.UpdateTaskConfigAsync( - task.Id, - BuildSyncTaskConfig( - trigger: trigger, - totalAccounts: snapshot?.Total ?? 0, - processedAccounts: snapshot?.Completed ?? 0, - failedAccounts: snapshot?.Failed ?? 0, - totalChannelsSynced: 0, - totalGroupsSynced: 0, - failures: Array.Empty(), - error: "已取消")); - await _taskManagement.CompleteTaskAsync(task.Id, success: false); - } - + var snapshot = await _taskManagement.GetTaskAsync(taskId); + await _taskManagement.UpdateTaskConfigAsync( + taskId, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: snapshot?.Total ?? 0, + processedAccounts: snapshot?.Completed ?? 0, + failedAccounts: snapshot?.Failed ?? 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty(), + error: "已取消")); + await _taskManagement.CompleteTaskAsync(taskId, success: false); throw; } catch (Exception ex) { - if (task != null) - { - var snapshot = await _taskManagement.GetTaskAsync(task.Id); - await _taskManagement.UpdateTaskConfigAsync( - task.Id, - BuildSyncTaskConfig( - trigger: trigger, - totalAccounts: snapshot?.Total ?? 0, - processedAccounts: snapshot?.Completed ?? 0, - failedAccounts: snapshot?.Failed ?? 0, - totalChannelsSynced: 0, - totalGroupsSynced: 0, - failures: Array.Empty(), - error: ex.Message)); - await _taskManagement.CompleteTaskAsync(task.Id, success: false); - } - + var snapshot = await _taskManagement.GetTaskAsync(taskId); + await _taskManagement.UpdateTaskConfigAsync( + taskId, + BuildSyncTaskConfig( + trigger: trigger, + totalAccounts: snapshot?.Total ?? 0, + processedAccounts: snapshot?.Completed ?? 0, + failedAccounts: snapshot?.Failed ?? 0, + totalChannelsSynced: 0, + totalGroupsSynced: 0, + failures: Array.Empty(), + error: ex.Message)); + await _taskManagement.CompleteTaskAsync(taskId, success: false); throw; } finally { - SyncGate.Release(); + if (gateEntered) + SyncGate.Release(); } } + private async Task> GetDistinctActiveAccountsAsync() + { + return (await _accountManagement.GetActiveAccountsAsync()) + .GroupBy(x => x.Id) + .Select(x => x.First()) + .ToList(); + } + public async Task SyncAllActiveAccountsAsync( CancellationToken cancellationToken, Func? progressCallback = null) @@ -191,10 +244,17 @@ public class DataSyncService var accountFailed = false; cancellationToken.ThrowIfCancellationRequested(); + _logger.LogInformation( + "Syncing account {Index}/{Total}: {AccountId} {Phone}", + index + 1, + accountList.Count, + account.Id, + account.Phone); + try { // 同步频道:拉取账号当前可见的全部频道,并记录该账号在频道中的角色关系。 - var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id); + var channelInfos = await _channelService.GetVisibleChannelsAsync(account.Id, cancellationToken); var keepChannelIds = new List(capacity: channelInfos.Count); foreach (var channelInfo in channelInfos) @@ -230,7 +290,7 @@ public class DataSyncService await _channelManagement.DeleteStaleAccountChannelsAsync(account.Id, keepChannelIds); // 同步群组:拉取账号当前可见的全部群组,并记录该账号在群组中的角色关系。 - var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id); + var groupInfos = await _groupService.GetVisibleGroupsAsync(account.Id, cancellationToken); var keepGroupIds = new List(capacity: groupInfos.Count); foreach (var groupInfo in groupInfos) diff --git a/src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs b/src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs new file mode 100644 index 0000000..26d5870 --- /dev/null +++ b/src/TelegramPanel.Web/Services/ModuleTaskEditorComponentResolver.cs @@ -0,0 +1,42 @@ +using TelegramPanel.Modules; +using TelegramPanel.Web.Modules; + +namespace TelegramPanel.Web.Services; + +public static class ModuleTaskEditorComponentResolver +{ + public static Type? ResolveCreateEditor(RegisteredTaskDefinition taskDefinition) + { + return Resolve(taskDefinition, taskDefinition.Definition.EditorComponentType); + } + + public static Type? ResolveEditEditor(RegisteredTaskDefinition taskDefinition) + { + var typeName = taskDefinition.Definition.TaskCenter.EditComponentType; + if (string.IsNullOrWhiteSpace(typeName)) + typeName = taskDefinition.Definition.EditorComponentType; + + return Resolve(taskDefinition, typeName); + } + + private static Type? Resolve(RegisteredTaskDefinition taskDefinition, string? typeName) + { + typeName = (typeName ?? string.Empty).Trim(); + if (typeName.Length == 0) + return null; + + var moduleTypeName = NormalizeTypeName(typeName); + return Type.GetType(typeName, throwOnError: false, ignoreCase: false) + ?? taskDefinition.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); + } + + public static string NormalizeTypeName(string? typeName) + { + typeName = (typeName ?? string.Empty).Trim(); + if (typeName.Length == 0) + return typeName; + + var comma = typeName.IndexOf(',', StringComparison.Ordinal); + return comma > 0 ? typeName[..comma].Trim() : typeName; + } +} diff --git a/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs new file mode 100644 index 0000000..ade6479 --- /dev/null +++ b/src/TelegramPanel.Web/Services/UserChatActiveTaskRerunBuilder.cs @@ -0,0 +1,120 @@ +using System.Text.Json; +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Services; + +public sealed class UserChatActiveTaskRerunBuilder : IModuleTaskRerunBuilder +{ + public string TaskType => BatchTaskTypes.UserChatActive; + + public ModuleTaskCreateRequest Build(ModuleTaskSnapshot task) + { + var rerunConfig = BuildRerunConfig(task.Config); + var total = rerunConfig.MaxMessages > 0 ? rerunConfig.MaxMessages : 0; + var configJson = JsonSerializer.Serialize(rerunConfig, new JsonSerializerOptions { WriteIndented = true }); + + return new ModuleTaskCreateRequest + { + TaskType = BatchTaskTypes.UserChatActive, + Total = total, + Config = configJson + }; + } + + private static UserChatActiveTaskConfig BuildRerunConfig(string? rawConfig) + { + var raw = (rawConfig ?? string.Empty).Trim(); + if (raw.Length == 0) + throw new InvalidOperationException("任务配置为空,无法重新运行"); + + UserChatActiveTaskConfig cfg; + try + { + cfg = JsonSerializer.Deserialize(raw) + ?? throw new InvalidOperationException("任务配置解析结果为空"); + } + catch (Exception ex) + { + throw new InvalidOperationException($"任务配置 JSON 无效:{ex.Message}"); + } + + cfg.CategoryIds = NormalizeCategoryIdsForRerun(cfg.CategoryIds, cfg.CategoryId); + if (cfg.CategoryIds.Count == 0) + throw new InvalidOperationException("任务缺少账号分类,无法重新运行"); + + cfg.CategoryId = cfg.CategoryIds[0]; + cfg.CategoryNames = NormalizeCategoryNamesForRerun(cfg.CategoryNames, cfg.CategoryName); + cfg.CategoryName = cfg.CategoryNames.FirstOrDefault() ?? cfg.CategoryName; + + cfg.Targets = (cfg.Targets ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + cfg.Dictionary = (cfg.Dictionary ?? new List()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .ToList(); + + if (cfg.Targets.Count == 0) + throw new InvalidOperationException("任务缺少目标群组/频道,无法重新运行"); + + if (cfg.Dictionary.Count == 0) + throw new InvalidOperationException("任务缺少词典消息,无法重新运行"); + + if (cfg.DelayMinMs < 0) cfg.DelayMinMs = 0; + if (cfg.DelayMaxMs < 0) cfg.DelayMaxMs = 0; + if (cfg.DelayMinMs > 600000) cfg.DelayMinMs = 600000; + if (cfg.DelayMaxMs > 600000) cfg.DelayMaxMs = 600000; + if (cfg.DelayMaxMs < cfg.DelayMinMs) cfg.DelayMaxMs = cfg.DelayMinMs; + if (cfg.MaxMessages < 0) cfg.MaxMessages = 0; + + cfg.AccountMode = NormalizeModeValue(cfg.AccountMode); + cfg.TargetMode = NormalizeModeValue(cfg.TargetMode); + cfg.MessageMode = NormalizeModeValue(cfg.MessageMode); + + cfg.Canceled = false; + cfg.Error = null; + cfg.RecentFailures = new List(); + + return cfg; + } + + private static List NormalizeCategoryIdsForRerun(IEnumerable? values, int fallback) + { + var ids = (values ?? Array.Empty()) + .Where(x => x > 0) + .Distinct() + .ToList(); + + if (ids.Count == 0 && fallback > 0) + ids.Add(fallback); + + return ids; + } + + private static List NormalizeCategoryNamesForRerun(IEnumerable? values, string? fallback) + { + var names = (values ?? Array.Empty()) + .Select(x => (x ?? string.Empty).Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + var fallbackName = (fallback ?? string.Empty).Trim(); + if (names.Count == 0 && fallbackName.Length > 0) + names.Add(fallbackName); + + return names; + } + + private static string NormalizeModeValue(string? mode) + { + return string.Equals((mode ?? string.Empty).Trim(), UserChatActiveTaskModes.Queue, StringComparison.OrdinalIgnoreCase) + ? UserChatActiveTaskModes.Queue + : UserChatActiveTaskModes.Random; + } +}