diff --git a/src/TelegramPanel.Core/Services/BotManagementService.cs b/src/TelegramPanel.Core/Services/BotManagementService.cs index 316621f..b4d0d2f 100644 --- a/src/TelegramPanel.Core/Services/BotManagementService.cs +++ b/src/TelegramPanel.Core/Services/BotManagementService.cs @@ -273,6 +273,7 @@ public class BotManagementService int botId, int? categoryId, string? search, + int statusFilter, int pageIndex, int pageSize, CancellationToken cancellationToken = default) @@ -282,6 +283,7 @@ public class BotManagementService categoryId: categoryId, broadcastOnly: true, search: search, + statusFilter: statusFilter, pageIndex: pageIndex, pageSize: pageSize, cancellationToken: cancellationToken); @@ -348,6 +350,23 @@ public class BotManagementService await _botChannelRepository.DeleteAsync(ch); } + public async Task UpdateChannelStatusAsync(int botId, long telegramId, bool ok, string? error, DateTime checkedAtUtc) + { + var ch = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId); + if (ch == null) + return; + + ch.ChannelStatusOk = ok; + ch.ChannelStatusCheckedAtUtc = checkedAtUtc; + ch.ChannelStatusError = ok + ? null + : string.IsNullOrWhiteSpace(error) + ? "检测失败" + : error.Trim()[..Math.Min(error.Trim().Length, 500)]; + + await _botChannelRepository.UpdateAsync(ch); + } + public async Task SetChannelCategoryAsync(int botChannelId, int? categoryId) { var ch = await _botChannelRepository.GetByIdAsync(botChannelId); diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index 9dd86af..c2ad2cf 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -37,6 +37,7 @@ public class BotTelegramService } public sealed record BotChannelSyncResult(int AppliedUpdates, int RemovedStale); + public sealed record ChannelStatusCheckResult(int SuccessCount, int FailedCount, int TotalCount, IReadOnlyDictionary Failures); /// /// 手动同步(新增 + 清理): @@ -632,6 +633,132 @@ public class BotTelegramService return map; } + /// + /// 批量检测 Bot 在频道中的可操作状态: + /// 通过 sendMessage 发送探测消息并立即 deleteMessage;发送失败则记为异常。 + /// + public async Task CheckChannelsStatusAsync( + int botId, + IReadOnlyList channelTelegramIds, + CancellationToken cancellationToken) + { + var bot = await _botManagement.GetBotAsync(botId) + ?? throw new InvalidOperationException($"机器人不存在:{botId}"); + + if (!bot.IsActive) + throw new InvalidOperationException("该机器人已停用"); + + var ids = channelTelegramIds + .Where(x => x != 0) + .Distinct() + .ToList(); + + if (ids.Count == 0) + return new ChannelStatusCheckResult(0, 0, 0, new Dictionary()); + + var failures = new Dictionary(); + + var delayMs = ReadInt(_configuration, "Telegram:DefaultDelayMs", 1000); + if (delayMs < 0) delayMs = 0; + if (delayMs > 10000) delayMs = 10000; + + var maxRetries = ReadInt(_configuration, "Telegram:MaxRetries", 1); + if (maxRetries < 0) maxRetries = 0; + if (maxRetries > 5) maxRetries = 5; + + var ok = 0; + for (var index = 0; index < ids.Count; index++) + { + var chatId = ids[index]; + cancellationToken.ThrowIfCancellationRequested(); + + var attempt = 0; + while (true) + { + try + { + await ProbeChannelBySendDeleteAsync(bot.Token, chatId, cancellationToken); + await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: true, error: null, checkedAtUtc: DateTime.UtcNow); + ok++; + break; + } + catch (InvalidOperationException ex) when (TryGetRetryAfterSeconds(ex.Message, out var seconds)) + { + attempt++; + if (attempt > maxRetries) + { + var msg = string.IsNullOrWhiteSpace(ex.Message) ? "检测失败(限流重试超限)" : ex.Message.Trim(); + failures[chatId] = msg; + await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: false, error: msg, checkedAtUtc: DateTime.UtcNow); + _logger.LogWarning( + "CheckChannelsStatus retry exceeded: bot={BotId} chat={ChatId} retryAfter={RetryAfter}s", + botId, + chatId, + seconds); + break; + } + + _logger.LogWarning( + "CheckChannelsStatus hit rate limit, retry after {Seconds}s (attempt {Attempt}/{Max}): bot={BotId} chat={ChatId}", + seconds, + attempt, + maxRetries, + botId, + chatId); + await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken); + } + catch (Exception ex) + { + var msg = string.IsNullOrWhiteSpace(ex.Message) ? "检测失败" : ex.Message.Trim(); + failures[chatId] = msg; + await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: false, error: msg, checkedAtUtc: DateTime.UtcNow); + _logger.LogWarning(ex, "CheckChannelsStatus failed: bot={BotId} chat={ChatId}", botId, chatId); + break; + } + } + + if (delayMs > 0 && index < ids.Count - 1) + { + var jitter = Random.Shared.Next(0, Math.Min(300, delayMs + 1)); + await Task.Delay(delayMs + jitter, cancellationToken); + } + } + + return new ChannelStatusCheckResult(ok, failures.Count, ids.Count, failures); + } + + private async Task ProbeChannelBySendDeleteAsync(string token, long chatId, CancellationToken cancellationToken) + { + var probeText = $"频道状态检测 {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}"; + var sendResult = await _api.CallAsync(token, "sendMessage", new Dictionary + { + ["chat_id"] = chatId.ToString(), + ["text"] = probeText, + ["disable_notification"] = "true" + }, cancellationToken); + + if (sendResult.ValueKind != JsonValueKind.Object + || !sendResult.TryGetProperty("message_id", out var messageIdEl) + || !messageIdEl.TryGetInt64(out var messageId)) + { + throw new InvalidOperationException("Bot API sendMessage 返回格式异常,无法完成频道检测"); + } + + try + { + await _api.CallAsync(token, "deleteMessage", new Dictionary + { + ["chat_id"] = chatId.ToString(), + ["message_id"] = messageId.ToString() + }, cancellationToken); + } + catch (Exception ex) + { + // 删除失败不判定为“频道异常”,但记录日志便于排查(例如 Bot 缺少删消息权限)。 + _logger.LogWarning(ex, "Probe deleteMessage failed: chat={ChatId}", chatId); + } + } + private static bool TryParseChatMemberUpdate(JsonElement myChatMember, out BotApiChat chat, out string status) { chat = default; diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index 17095d8..6a449cf 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -177,10 +177,12 @@ public class AppDbContext : DbContext entity.Property(e => e.Title).IsRequired().HasMaxLength(200); entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); + entity.Property(e => e.ChannelStatusError).HasMaxLength(500); entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); entity.HasIndex(e => e.CategoryId); + entity.HasIndex(e => e.ChannelStatusOk); entity.HasOne(e => e.Category) .WithMany(c => c.Channels) diff --git a/src/TelegramPanel.Data/Entities/BotChannel.cs b/src/TelegramPanel.Data/Entities/BotChannel.cs index 9b7b77a..edd9467 100644 --- a/src/TelegramPanel.Data/Entities/BotChannel.cs +++ b/src/TelegramPanel.Data/Entities/BotChannel.cs @@ -16,6 +16,21 @@ public class BotChannel public DateTime? CreatedAt { get; set; } public DateTime SyncedAt { get; set; } = DateTime.UtcNow; + /// + /// 频道状态检测结果:true=正常,false=异常,null=未检测 + /// + public bool? ChannelStatusOk { get; set; } + + /// + /// 最近一次频道状态检测时间(UTC) + /// + public DateTime? ChannelStatusCheckedAtUtc { get; set; } + + /// + /// 最近一次检测失败原因(仅异常时有值) + /// + public string? ChannelStatusError { get; set; } + public int? CategoryId { get; set; } public BotChannelCategory? Category { get; set; } diff --git a/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs new file mode 100644 index 0000000..31e34c1 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.Designer.cs @@ -0,0 +1,632 @@ +// +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("20260302171929_AddBotChannelStatusFields")] + partial class AddBotChannelStatusFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.22"); + + 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.ToTable("AccountChannels"); + }); + + 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("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("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("CreatorAccountId"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + 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.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.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatorAccount"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Navigation("AccountChannels"); + + 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"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs new file mode 100644 index 0000000..c92a086 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260302171929_AddBotChannelStatusFields.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddBotChannelStatusFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ChannelStatusCheckedAtUtc", + table: "BotChannels", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "ChannelStatusError", + table: "BotChannels", + type: "TEXT", + maxLength: 500, + nullable: true); + + migrationBuilder.AddColumn( + name: "ChannelStatusOk", + table: "BotChannels", + type: "INTEGER", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_ChannelStatusOk", + table: "BotChannels", + column: "ChannelStatusOk"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_BotChannels_ChannelStatusOk", + table: "BotChannels"); + + migrationBuilder.DropColumn( + name: "ChannelStatusCheckedAtUtc", + table: "BotChannels"); + + migrationBuilder.DropColumn( + name: "ChannelStatusError", + table: "BotChannels"); + + migrationBuilder.DropColumn( + name: "ChannelStatusOk", + table: "BotChannels"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 4c5a4f8..1eba4bc 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -266,6 +266,16 @@ namespace TelegramPanel.Data.Migrations 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"); @@ -294,6 +304,8 @@ namespace TelegramPanel.Data.Migrations b.HasIndex("CategoryId"); + b.HasIndex("ChannelStatusOk"); + b.HasIndex("TelegramId") .IsUnique(); diff --git a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs index e54858d..a7f9d64 100644 --- a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs @@ -9,7 +9,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor { } - private IQueryable BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search) + private IQueryable BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search, int statusFilter) { // 兼容旧模块/旧 UI 约定:categoryId=0 表示“不筛选分类” if (categoryId == 0) @@ -32,6 +32,16 @@ public class BotChannelRepository : Repository, IBotChannelRepositor if (broadcastOnly) query = query.Where(x => x.IsBroadcast); + // 频道状态筛选: + // 0=全部,1=正常,2=异常,3=未检测 + query = statusFilter switch + { + 1 => query.Where(x => x.ChannelStatusOk == true), + 2 => query.Where(x => x.ChannelStatusOk == false), + 3 => query.Where(x => x.ChannelStatusOk == null), + _ => query + }; + search = (search ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(search)) { @@ -87,6 +97,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor int? categoryId, bool broadcastOnly, string? search, + int statusFilter, int pageIndex, int pageSize, CancellationToken cancellationToken = default) @@ -95,7 +106,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor if (pageSize <= 0) pageSize = 20; if (pageSize > 500) pageSize = 500; - var query = BuildQuery(botId, categoryId, broadcastOnly, search); + var query = BuildQuery(botId, categoryId, broadcastOnly, search, statusFilter); var total = await query.CountAsync(cancellationToken); var items = await query .Skip(pageIndex * pageSize) diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs index af766da..78473c3 100644 --- a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs @@ -13,6 +13,7 @@ public interface IBotChannelRepository : IRepository int? categoryId, bool broadcastOnly, string? search, + int statusFilter, int pageIndex, int pageSize, CancellationToken cancellationToken = default); diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor index 856fc95..269d10c 100644 --- a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor +++ b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor @@ -35,6 +35,15 @@ } + + 全部状态 + 正常 + 异常 + 未检测 + + @@ -72,12 +81,14 @@ + 检测状态(已选) 导出邀请(已选) 批量邀请成员(已选) 批量设置管理员(账号执行) 批量设置管理员(机器人/ID) 批量设置分类(已选) 踢出用户(已选) + 删除频道(已选) @if (selected.Count > 0) @@ -99,6 +110,7 @@ 频道名称 用户名 分类 + 频道状态 最后同步 操作 @@ -126,15 +138,26 @@ 未分类 } + + + + @GetChannelStatusText(context) + + + + + @@ -172,6 +195,7 @@ private HashSet selected = new(); private int filterCategoryId = 0; + private int filterStatus = 0; private string search = ""; private bool _actionsExpanded = true; private CancellationTokenSource? _searchDebounceCts; @@ -227,6 +251,7 @@ selectedBotId = botId; filterCategoryId = 0; + filterStatus = 0; search = ""; selected.Clear(); @@ -285,6 +310,7 @@ botId: selectedBotId, categoryId: filterCategoryId == -1 ? -1 : (filterCategoryId > 0 ? filterCategoryId : null), search: search, + statusFilter: filterStatus, pageIndex: state.Page, pageSize: state.PageSize, cancellationToken: cancellationToken); @@ -367,6 +393,182 @@ } } + private async Task CheckSingleChannelStatus(BotChannel channel) + { + if (selectedBotId <= 0) + return; + + loading = true; + try + { + var result = await BotTelegram.CheckChannelsStatusAsync( + selectedBotId, + new List { channel.TelegramId }, + CancellationToken.None); + + if (result.FailedCount == 0) + { + Snackbar.Add($"频道“{channel.Title}”检测正常", Severity.Success); + } + else + { + var msg = result.Failures.TryGetValue(channel.TelegramId, out var err) ? err : "检测失败"; + Snackbar.Add($"频道“{channel.Title}”检测异常:{msg}", Severity.Warning); + } + + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"检测失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task CheckSelectedChannelsStatus() + { + if (selectedBotId <= 0) + return; + + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + bool? ok = await DialogService.ShowMessageBox( + "确认检测", + $"将检测 {selected.Count} 个频道的状态(发送一条探测消息并立即删除)。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (ok != true) + return; + + loading = true; + try + { + var ids = selected.Select(x => x.TelegramId).Distinct().ToList(); + var result = await BotTelegram.CheckChannelsStatusAsync(selectedBotId, ids, CancellationToken.None); + + if (result.FailedCount == 0) + { + Snackbar.Add($"检测完成:{result.SuccessCount} 个频道均正常", Severity.Success); + } + else + { + var first = result.Failures.FirstOrDefault(); + var msg = $"检测完成:正常 {result.SuccessCount} 个,异常 {result.FailedCount} 个"; + if (first.Key != 0) + msg += $"(首个失败:{first.Value})"; + Snackbar.Add(msg, Severity.Warning); + } + + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"检测失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task DeleteSingleChannelAsync(BotChannel channel) + { + if (selectedBotId <= 0) + return; + + var channelName = string.IsNullOrWhiteSpace(channel.Title) ? channel.TelegramId.ToString() : channel.Title; + bool? ok = await DialogService.ShowMessageBox( + "确认删除", + $"确定删除频道“{channelName}”吗?此操作只会移除面板中的 Bot 频道记录。", + yesText: "删除", cancelText: "取消"); + + if (ok != true) + return; + + loading = true; + try + { + await BotManagement.DeleteChannelByTelegramIdAsync(selectedBotId, channel.TelegramId); + Snackbar.Add($"已删除频道:{channelName}", Severity.Success); + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"删除失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task DeleteSelectedChannelsAsync() + { + if (selectedBotId <= 0) + return; + + if (selected.Count == 0) + { + Snackbar.Add("请先选择频道", Severity.Info); + return; + } + + bool? ok = await DialogService.ShowMessageBox( + "确认批量删除", + $"确定删除已选 {selected.Count} 个频道吗?此操作只会移除面板中的 Bot 频道记录。", + yesText: "删除", cancelText: "取消"); + + if (ok != true) + return; + + loading = true; + try + { + var ids = selected.Select(x => x.TelegramId).Distinct().ToList(); + var success = 0; + var failed = 0; + + foreach (var chatId in ids) + { + try + { + await BotManagement.DeleteChannelByTelegramIdAsync(selectedBotId, chatId); + success++; + } + catch + { + failed++; + } + } + + if (failed == 0) + { + Snackbar.Add($"批量删除完成:成功 {success} 个频道", Severity.Success); + } + else + { + Snackbar.Add($"批量删除完成:成功 {success} 个,失败 {failed} 个", Severity.Warning); + } + + await ReloadTable(); + } + catch (Exception ex) + { + Snackbar.Add($"批量删除失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + private async Task OpenCreateCategoryDialog() { if (selectedBotId <= 0) @@ -741,6 +943,39 @@ await LoadBotData(); } + private static string GetChannelStatusText(BotChannel channel) + { + return channel.ChannelStatusOk switch + { + true => "正常", + false => "异常", + null => "未检测" + }; + } + + private static Color GetChannelStatusColor(BotChannel channel) + { + return channel.ChannelStatusOk switch + { + true => Color.Success, + false => Color.Error, + null => Color.Default + }; + } + + private static string GetChannelStatusTooltip(BotChannel channel) + { + if (channel.ChannelStatusOk == null) + return "尚未检测"; + + var checkedAt = channel.ChannelStatusCheckedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-"; + if (channel.ChannelStatusOk == true) + return $"最近检测:{checkedAt}"; + + var err = string.IsNullOrWhiteSpace(channel.ChannelStatusError) ? "未知错误" : channel.ChannelStatusError; + return $"最近检测:{checkedAt};失败原因:{err}"; + } + private Task SelectAllFiltered() { selected = channels.ToHashSet();