feat(bot-channels): 支持删除频道与状态检测筛选

This commit is contained in:
meoacgx committed 2026-03-03 01:26:50 +08:00
1 parent a4669f8561
commit 8a6cdd4868
10 files changed
+1115 -2

No files matched your search

@@ -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);
@@ -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<long, string> Failures);
/// <summary>
/// 手动同步(新增 + 清理):
@@ -632,6 +633,132 @@ public class BotTelegramService
return map;
}
/// <summary>
/// 批量检测 Bot 在频道中的可操作状态:
/// 通过 sendMessage 发送探测消息并立即 deleteMessage;发送失败则记为异常。
/// </summary>
public async Task<ChannelStatusCheckResult> CheckChannelsStatusAsync(
int botId,
IReadOnlyList<long> 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<long, string>());
var failures = new Dictionary<long, string>();
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<string, string?>
{
["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<string, string?>
{
["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;
+2
View File
@@ -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)
@@ -16,6 +16,21 @@ public class BotChannel
public DateTime? CreatedAt { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 频道状态检测结果:true=正常,false=异常,null=未检测
/// </summary>
public bool? ChannelStatusOk { get; set; }
/// <summary>
/// 最近一次频道状态检测时间(UTC)
/// </summary>
public DateTime? ChannelStatusCheckedAtUtc { get; set; }
/// <summary>
/// 最近一次检测失败原因(仅异常时有值)
/// </summary>
public string? ChannelStatusError { get; set; }
public int? CategoryId { get; set; }
public BotChannelCategory? Category { get; set; }
@@ -0,0 +1,632 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddBotChannelStatusFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ChannelStatusCheckedAtUtc",
table: "BotChannels",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChannelStatusError",
table: "BotChannels",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ChannelStatusOk",
table: "BotChannels",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_BotChannels_ChannelStatusOk",
table: "BotChannels",
column: "ChannelStatusOk");
}
/// <inheritdoc />
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");
}
}
}
@@ -266,6 +266,16 @@ namespace TelegramPanel.Data.Migrations
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
@@ -294,6 +304,8 @@ namespace TelegramPanel.Data.Migrations
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
@@ -9,7 +9,7 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
{
}
private IQueryable<BotChannel> BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search)
private IQueryable<BotChannel> 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<BotChannel>, 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<BotChannel>, IBotChannelRepositor
int? categoryId,
bool broadcastOnly,
string? search,
int statusFilter,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
@@ -95,7 +106,7 @@ public class BotChannelRepository : Repository<BotChannel>, 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)
@@ -13,6 +13,7 @@ public interface IBotChannelRepository : IRepository<BotChannel>
int? categoryId,
bool broadcastOnly,
string? search,
int statusFilter,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default);
@@ -35,6 +35,15 @@
}
</MudSelect>
<MudSelect @bind-Value="filterStatus" @bind-Value:after="OnFiltersChanged" Label="频道状态" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs"
Style="min-width: 160px; flex: 0 1 200px;" Disabled="@(selectedBotId <= 0)">
<MudSelectItem Value="0">全部状态</MudSelectItem>
<MudSelectItem Value="1">正常</MudSelectItem>
<MudSelectItem Value="2">异常</MudSelectItem>
<MudSelectItem Value="3">未检测</MudSelectItem>
</MudSelect>
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small"
Title="新建分类" OnClick="OpenCreateCategoryDialog"
Disabled="@(selectedBotId <= 0)" />
@@ -72,12 +81,14 @@
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@(loading || selectedBotId <= 0)" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="CheckSelectedChannelsStatus">检测状态(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="ExportInvites">导出邀请(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchInviteMembers">批量邀请成员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetAdminsByAccount">批量设置管理员(账号执行)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenSetAdmins">批量设置管理员(机器人/ID)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetCategory">批量设置分类(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBanMember">踢出用户(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="DeleteSelectedChannelsAsync">删除频道(已选)</MudMenuItem>
</MudMenu>
@if (selected.Count > 0)
@@ -99,6 +110,7 @@
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>分类</MudTh>
<MudTh>频道状态</MudTh>
<MudTh>最后同步</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
@@ -126,15 +138,26 @@
<MudText Typo="Typo.body2" Color="Color.Default">未分类</MudText>
}
</MudTd>
<MudTd DataLabel="频道状态">
<MudTooltip Text="@GetChannelStatusTooltip(context)">
<MudChip T="string" Size="Size.Small" Color="@GetChannelStatusColor(context)">
@GetChannelStatusText(context)
</MudChip>
</MudTooltip>
</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Color="Color.Success"
OnClick="@(async () => await CheckSingleChannelStatus(context))" title="检测状态" />
<MudIconButton Icon="@Icons.Material.Filled.Link" Size="Size.Small" Color="Color.Info"
OnClick="@(async () => await CopyLinkAsync(context))" Title="复制链接" />
<MudIconButton Icon="@Icons.Material.Filled.Info" Size="Size.Small" Color="Color.Default"
OnClick="@(async () => await ShowDetailsAsync(context))" Title="查看详情" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(async () => await EditChannelAsync(context))" Title="编辑频道" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await DeleteSingleChannelAsync(context))" title="删除频道" />
</MudStack>
</MudTd>
</RowTemplate>
@@ -172,6 +195,7 @@
private HashSet<BotChannel> 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<long> { 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();