From 7d8f93b4c855ac5cf2a5426d9aafc0e360df286e Mon Sep 17 00:00:00 2001 From: meoacgx Date: Sat, 3 Jan 2026 19:11:27 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20Bot=E9=A2=91=E9=81=93=E5=85=A8?= =?UTF-8?q?=E5=B1=80=E5=85=B1=E4=BA=AB=EF=BC=88=E5=85=B1=E7=94=A8=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E4=B8=8E=E9=A2=91=E9=81=93=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/BotManagementService.cs | 49 +- .../Services/Telegram/BotTelegramService.cs | 3 +- src/TelegramPanel.Data/AppDbContext.cs | 34 +- .../DesignTimeDbContextFactory.cs | 20 + src/TelegramPanel.Data/Entities/Bot.cs | 3 +- src/TelegramPanel.Data/Entities/BotChannel.cs | 4 +- .../Entities/BotChannelCategory.cs | 3 - .../Entities/BotChannelMember.cs | 18 + ...103094343_GlobalizeBotChannels.Designer.cs | 620 ++++++++++++++++++ .../20260103094343_GlobalizeBotChannels.cs | 298 +++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 261 +++++++- .../BotChannelCategoryRepository.cs | 8 +- .../BotChannelMemberRepository.cs | 52 ++ .../Repositories/BotChannelRepository.cs | 21 +- .../Repositories/BotRepository.cs | 4 +- .../IBotChannelCategoryRepository.cs | 5 +- .../IBotChannelMemberRepository.cs | 12 + .../Repositories/IBotChannelRepository.cs | 1 + .../ServiceCollectionExtensions.cs | 1 + .../Dialogs/BotChannelCategoryDialog.razor | 3 +- .../Dialogs/BotChannelEditDialog.razor | 3 +- .../Components/Pages/BotChannelsHome.razor | 7 +- .../Components/Pages/Bots.razor | 8 +- 23 files changed, 1362 insertions(+), 76 deletions(-) create mode 100644 src/TelegramPanel.Data/DesignTimeDbContextFactory.cs create mode 100644 src/TelegramPanel.Data/Entities/BotChannelMember.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.Designer.cs create mode 100644 src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.cs create mode 100644 src/TelegramPanel.Data/Repositories/BotChannelMemberRepository.cs create mode 100644 src/TelegramPanel.Data/Repositories/IBotChannelMemberRepository.cs diff --git a/src/TelegramPanel.Core/Services/BotManagementService.cs b/src/TelegramPanel.Core/Services/BotManagementService.cs index 0b30311..45828d2 100644 --- a/src/TelegramPanel.Core/Services/BotManagementService.cs +++ b/src/TelegramPanel.Core/Services/BotManagementService.cs @@ -10,17 +10,20 @@ public class BotManagementService private readonly IBotRepository _botRepository; private readonly IBotChannelRepository _botChannelRepository; private readonly IBotChannelCategoryRepository _categoryRepository; + private readonly IBotChannelMemberRepository _memberRepository; private readonly ILogger _logger; public BotManagementService( IBotRepository botRepository, IBotChannelRepository botChannelRepository, IBotChannelCategoryRepository categoryRepository, + IBotChannelMemberRepository memberRepository, ILogger logger) { _botRepository = botRepository; _botChannelRepository = botChannelRepository; _categoryRepository = categoryRepository; + _memberRepository = memberRepository; _logger = logger; } @@ -96,28 +99,25 @@ public class BotManagementService } } - public async Task> GetCategoriesAsync(int botId) + public async Task> GetCategoriesAsync() { - return await _categoryRepository.GetForBotAsync(botId); + return await _categoryRepository.GetAllOrderedAsync(); } - public async Task CreateCategoryAsync(int botId, string name, string? description = null) + public async Task CreateCategoryAsync(string name, string? description = null) { name = (name ?? string.Empty).Trim(); description = string.IsNullOrWhiteSpace(description) ? null : description.Trim(); - if (botId <= 0) - throw new ArgumentException("botId 无效", nameof(botId)); if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("分类名称不能为空", nameof(name)); - var existing = await _categoryRepository.GetByNameAsync(botId, name); + var existing = await _categoryRepository.GetByNameAsync(name); if (existing != null) throw new InvalidOperationException("分类名称已存在"); var cat = new BotChannelCategory { - BotId = botId, Name = name, Description = description, CreatedAt = DateTime.UtcNow @@ -173,15 +173,22 @@ public class BotManagementService return await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId); } - public async Task UpsertChannelAsync(BotChannel channel) + public async Task UpsertChannelAsync(int botId, BotChannel channel) { - if (channel.BotId <= 0) - throw new ArgumentException("BotId 无效", nameof(channel)); + if (botId <= 0) + throw new ArgumentException("BotId 无效", nameof(botId)); if (channel.TelegramId == 0) throw new ArgumentException("TelegramId 无效", nameof(channel)); - var existing = await _botChannelRepository.GetByTelegramIdAsync(channel.BotId, channel.TelegramId); - if (existing != null) + var now = DateTime.UtcNow; + + var existing = await _botChannelRepository.GetGlobalByTelegramIdAsync(channel.TelegramId); + if (existing == null) + { + channel.SyncedAt = now; + existing = await _botChannelRepository.AddAsync(channel); + } + else { existing.Title = channel.Title; existing.Username = channel.Username; @@ -191,23 +198,27 @@ public class BotManagementService existing.AccessHash = channel.AccessHash; if (channel.CreatedAt.HasValue) existing.CreatedAt = channel.CreatedAt; - existing.SyncedAt = DateTime.UtcNow; + existing.SyncedAt = now; await _botChannelRepository.UpdateAsync(existing); - return existing; } - channel.SyncedAt = DateTime.UtcNow; - return await _botChannelRepository.AddAsync(channel); + await _memberRepository.UpsertAsync(botId, existing.Id, now); + return existing; } public async Task DeleteChannelByTelegramIdAsync(int botId, long telegramId) { - var existing = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId); - if (existing == null) + var ch = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId); + if (ch == null) return; - await _botChannelRepository.DeleteAsync(existing); + await _memberRepository.DeleteAsync(botId, ch.Id); + + // 清理无任何 Bot 关联的“孤儿频道” + var remains = await _memberRepository.CountForChannelAsync(ch.Id); + if (remains == 0) + await _botChannelRepository.DeleteAsync(ch); } public async Task SetChannelCategoryAsync(int botChannelId, int? categoryId) diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index 648ea73..9545ab9 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -256,9 +256,8 @@ public class BotTelegramService // left/kicked 或降权为 member 等,直接从列表移除(否则导出邀请会失败且列表不准)。 if (change.Status is "administrator" or "creator") { - await _botManagement.UpsertChannelAsync(new BotChannel + await _botManagement.UpsertChannelAsync(bot.Id, new BotChannel { - BotId = bot.Id, TelegramId = chatId, Title = string.IsNullOrWhiteSpace(change.Chat.Title) ? $"频道 {chatId}" : change.Chat.Title, Username = string.IsNullOrWhiteSpace(change.Chat.Username) ? null : change.Chat.Username.Trim().TrimStart('@'), diff --git a/src/TelegramPanel.Data/AppDbContext.cs b/src/TelegramPanel.Data/AppDbContext.cs index abe16ca..17095d8 100644 --- a/src/TelegramPanel.Data/AppDbContext.cs +++ b/src/TelegramPanel.Data/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext : DbContext public DbSet Bots => Set(); public DbSet BotChannels => Set(); public DbSet BotChannelCategories => Set(); + public DbSet BotChannelMembers => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -166,12 +167,7 @@ public class AppDbContext : DbContext entity.Property(e => e.Name).IsRequired().HasMaxLength(100); entity.Property(e => e.Description).HasMaxLength(500); - entity.HasIndex(e => new { e.BotId, e.Name }).IsUnique(); - - entity.HasOne(e => e.Bot) - .WithMany(b => b.Categories) - .HasForeignKey(e => e.BotId) - .OnDelete(DeleteBehavior.Cascade); + entity.HasIndex(e => e.Name).IsUnique(); }); // BotChannel配置 @@ -182,19 +178,33 @@ public class AppDbContext : DbContext entity.Property(e => e.Username).HasMaxLength(100); entity.Property(e => e.About).HasMaxLength(1000); - entity.HasIndex(e => new { e.BotId, e.TelegramId }).IsUnique(); + entity.HasIndex(e => e.TelegramId).IsUnique(); entity.HasIndex(e => e.Username); entity.HasIndex(e => e.CategoryId); - entity.HasOne(e => e.Bot) - .WithMany(b => b.Channels) - .HasForeignKey(e => e.BotId) - .OnDelete(DeleteBehavior.Cascade); - entity.HasOne(e => e.Category) .WithMany(c => c.Channels) .HasForeignKey(e => e.CategoryId) .OnDelete(DeleteBehavior.SetNull); }); + + // BotChannelMember 配置 + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => new { e.BotId, e.BotChannelId }).IsUnique(); + entity.HasIndex(e => e.BotId); + entity.HasIndex(e => e.BotChannelId); + + entity.HasOne(e => e.Bot) + .WithMany(b => b.ChannelMembers) + .HasForeignKey(e => e.BotId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(e => e.BotChannel) + .WithMany(c => c.Members) + .HasForeignKey(e => e.BotChannelId) + .OnDelete(DeleteBehavior.Cascade); + }); } } diff --git a/src/TelegramPanel.Data/DesignTimeDbContextFactory.cs b/src/TelegramPanel.Data/DesignTimeDbContextFactory.cs new file mode 100644 index 0000000..c78b2ca --- /dev/null +++ b/src/TelegramPanel.Data/DesignTimeDbContextFactory.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TelegramPanel.Data; + +/// +/// 仅用于 dotnet-ef 在设计时创建 DbContext(不影响运行时)。 +/// +public sealed class DesignTimeDbContextFactory : IDesignTimeDbContextFactory +{ + public AppDbContext CreateDbContext(string[] args) + { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=design-time.db") + .Options; + + return new AppDbContext(options); + } +} + diff --git a/src/TelegramPanel.Data/Entities/Bot.cs b/src/TelegramPanel.Data/Entities/Bot.cs index 0c9e5f5..569079c 100644 --- a/src/TelegramPanel.Data/Entities/Bot.cs +++ b/src/TelegramPanel.Data/Entities/Bot.cs @@ -14,6 +14,5 @@ public class Bot public DateTime? LastSyncAt { get; set; } public long? LastUpdateId { get; set; } - public ICollection Categories { get; set; } = new List(); - public ICollection Channels { get; set; } = new List(); + public ICollection ChannelMembers { get; set; } = new List(); } diff --git a/src/TelegramPanel.Data/Entities/BotChannel.cs b/src/TelegramPanel.Data/Entities/BotChannel.cs index 5450978..9b7b77a 100644 --- a/src/TelegramPanel.Data/Entities/BotChannel.cs +++ b/src/TelegramPanel.Data/Entities/BotChannel.cs @@ -6,7 +6,6 @@ namespace TelegramPanel.Data.Entities; public class BotChannel { public int Id { get; set; } - public int BotId { get; set; } public long TelegramId { get; set; } public long? AccessHash { get; set; } public string Title { get; set; } = null!; @@ -19,7 +18,6 @@ public class BotChannel public int? CategoryId { get; set; } - public Bot? Bot { get; set; } public BotChannelCategory? Category { get; set; } + public ICollection Members { get; set; } = new List(); } - diff --git a/src/TelegramPanel.Data/Entities/BotChannelCategory.cs b/src/TelegramPanel.Data/Entities/BotChannelCategory.cs index 6de173a..31c64e4 100644 --- a/src/TelegramPanel.Data/Entities/BotChannelCategory.cs +++ b/src/TelegramPanel.Data/Entities/BotChannelCategory.cs @@ -6,12 +6,9 @@ namespace TelegramPanel.Data.Entities; public class BotChannelCategory { public int Id { get; set; } - public int BotId { get; set; } public string Name { get; set; } = null!; public string? Description { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; - public Bot? Bot { get; set; } public ICollection Channels { get; set; } = new List(); } - diff --git a/src/TelegramPanel.Data/Entities/BotChannelMember.cs b/src/TelegramPanel.Data/Entities/BotChannelMember.cs new file mode 100644 index 0000000..c346ded --- /dev/null +++ b/src/TelegramPanel.Data/Entities/BotChannelMember.cs @@ -0,0 +1,18 @@ +namespace TelegramPanel.Data.Entities; + +/// +/// Bot - 频道/群 成员关系(用于记录“哪个 Bot 在哪个聊天里”)。 +/// +public class BotChannelMember +{ + public int Id { get; set; } + + public int BotId { get; set; } + public int BotChannelId { get; set; } + + public DateTime SyncedAt { get; set; } = DateTime.UtcNow; + + public Bot Bot { get; set; } = null!; + public BotChannel BotChannel { get; set; } = null!; +} + diff --git a/src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.Designer.cs b/src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.Designer.cs new file mode 100644 index 0000000..bb046cc --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.Designer.cs @@ -0,0 +1,620 @@ +// +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("20260103094343_GlobalizeBotChannels")] + partial class GlobalizeBotChannels + { + /// + 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("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("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/20260103094343_GlobalizeBotChannels.cs b/src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.cs new file mode 100644 index 0000000..9c60668 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260103094343_GlobalizeBotChannels.cs @@ -0,0 +1,298 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class GlobalizeBotChannels : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 将“每个 Bot 一套 BotChannels/BotChannelCategories”的旧结构,升级为: + // - BotChannels:全局唯一(TelegramId 唯一) + // - BotChannelCategories:全局唯一(Name 唯一) + // - BotChannelMembers:Bot-频道关系(哪个 Bot 在哪个频道里) + // + // 分类合并策略(用户选择 A):同一 TelegramId 在多 Bot 下若存在不同分类, + // 取“出现次数最多”的分类作为全局分类。 + + migrationBuilder.Sql("ALTER TABLE BotChannels RENAME TO BotChannels_Old;"); + migrationBuilder.Sql("ALTER TABLE BotChannelCategories RENAME TO BotChannelCategories_Old;"); + + // SQLite:表重命名后索引名不会自动变更;为避免与新表的同名索引冲突,这里先删旧索引(数据复制不依赖它们)。 + migrationBuilder.Sql("DROP INDEX IF EXISTS IX_BotChannels_BotId_TelegramId;"); + migrationBuilder.Sql("DROP INDEX IF EXISTS IX_BotChannels_Username;"); + migrationBuilder.Sql("DROP INDEX IF EXISTS IX_BotChannels_CategoryId;"); + migrationBuilder.Sql("DROP INDEX IF EXISTS IX_BotChannelCategories_BotId_Name;"); + + migrationBuilder.CreateTable( + name: "BotChannelCategories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannelCategories", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "BotChannels", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TelegramId = table.Column(type: "INTEGER", nullable: false), + AccessHash = table.Column(type: "INTEGER", nullable: true), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Username = table.Column(type: "TEXT", maxLength: 100, nullable: true), + IsBroadcast = table.Column(type: "INTEGER", nullable: false), + MemberCount = table.Column(type: "INTEGER", nullable: false), + About = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: true), + SyncedAt = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannels", x => x.Id); + table.ForeignKey( + name: "FK_BotChannels_BotChannelCategories_CategoryId", + column: x => x.CategoryId, + principalTable: "BotChannelCategories", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "BotChannelMembers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + BotId = table.Column(type: "INTEGER", nullable: false), + BotChannelId = table.Column(type: "INTEGER", nullable: false), + SyncedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannelMembers", x => x.Id); + table.ForeignKey( + name: "FK_BotChannelMembers_BotChannels_BotChannelId", + column: x => x.BotChannelId, + principalTable: "BotChannels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BotChannelMembers_Bots_BotId", + column: x => x.BotId, + principalTable: "Bots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BotChannelCategories_Name", + table: "BotChannelCategories", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_TelegramId", + table: "BotChannels", + column: "TelegramId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_Username", + table: "BotChannels", + column: "Username"); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_CategoryId", + table: "BotChannels", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_BotChannelMembers_BotId_BotChannelId", + table: "BotChannelMembers", + columns: new[] { "BotId", "BotChannelId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannelMembers_BotId", + table: "BotChannelMembers", + column: "BotId"); + + migrationBuilder.CreateIndex( + name: "IX_BotChannelMembers_BotChannelId", + table: "BotChannelMembers", + column: "BotChannelId"); + + // 1) 迁移分类(去重:Name 全局唯一) + migrationBuilder.Sql(@" +INSERT INTO BotChannelCategories (Name, Description, CreatedAt) +SELECT Name, MAX(Description), MIN(CreatedAt) +FROM BotChannelCategories_Old +GROUP BY Name; +"); + + // 2) 迁移频道(TelegramId 全局唯一;取 SyncedAt 最新的一条作为全局信息) + migrationBuilder.Sql(@" +INSERT INTO BotChannels (TelegramId, AccessHash, Title, Username, IsBroadcast, MemberCount, About, CreatedAt, SyncedAt, CategoryId) +SELECT bc.TelegramId, bc.AccessHash, bc.Title, bc.Username, bc.IsBroadcast, bc.MemberCount, bc.About, bc.CreatedAt, bc.SyncedAt, NULL +FROM BotChannels_Old bc +JOIN ( + SELECT TelegramId, MAX(SyncedAt) AS MaxSyncedAt + FROM BotChannels_Old + GROUP BY TelegramId +) m ON m.TelegramId = bc.TelegramId AND m.MaxSyncedAt = bc.SyncedAt +JOIN ( + SELECT TelegramId, SyncedAt, MAX(Id) AS MaxId + FROM BotChannels_Old + GROUP BY TelegramId, SyncedAt +) m2 ON m2.TelegramId = bc.TelegramId AND m2.SyncedAt = bc.SyncedAt AND m2.MaxId = bc.Id; +"); + + // 3) 迁移 Bot-频道关系(同一 bot + 同一频道去重,SyncedAt 取最大) + migrationBuilder.Sql(@" +INSERT INTO BotChannelMembers (BotId, BotChannelId, SyncedAt) +SELECT old.BotId, now.Id AS BotChannelId, MAX(old.SyncedAt) AS SyncedAt +FROM BotChannels_Old old +JOIN BotChannels now ON now.TelegramId = old.TelegramId +GROUP BY old.BotId, now.Id; +"); + + // 4) 合并分类:同一 TelegramId 在多 bot 下若存在不同分类,取“出现次数最多”的分类(方案 A) + migrationBuilder.Sql(@" +WITH CatNames AS ( + SELECT bc.TelegramId AS TelegramId, c.Name AS CatName + FROM BotChannels_Old bc + JOIN BotChannelCategories_Old c ON c.Id = bc.CategoryId + WHERE bc.CategoryId IS NOT NULL +), +Counts AS ( + SELECT TelegramId, CatName, COUNT(*) AS Cnt + FROM CatNames + GROUP BY TelegramId, CatName +), +Pick AS ( + SELECT TelegramId, CatName + FROM ( + SELECT TelegramId, CatName, Cnt, + ROW_NUMBER() OVER (PARTITION BY TelegramId ORDER BY Cnt DESC, CatName ASC) AS rn + FROM Counts + ) + WHERE rn = 1 +) +UPDATE BotChannels +SET CategoryId = ( + SELECT catNew.Id + FROM Pick p + JOIN BotChannelCategories catNew ON catNew.Name = p.CatName + WHERE p.TelegramId = BotChannels.TelegramId +) +WHERE TelegramId IN (SELECT TelegramId FROM Pick); +"); + + // 5) 清理旧表 + migrationBuilder.Sql("DROP TABLE BotChannels_Old;"); + migrationBuilder.Sql("DROP TABLE BotChannelCategories_Old;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // 降级不做数据回填(不可逆):仅恢复旧结构的空表,避免 Down 失败阻塞回滚链路。 + migrationBuilder.DropTable(name: "BotChannelMembers"); + migrationBuilder.DropTable(name: "BotChannels"); + migrationBuilder.DropTable(name: "BotChannelCategories"); + + migrationBuilder.CreateTable( + name: "BotChannelCategories", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + BotId = table.Column(type: "INTEGER", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Description = table.Column(type: "TEXT", maxLength: 500, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannelCategories", x => x.Id); + table.ForeignKey( + name: "FK_BotChannelCategories_Bots_BotId", + column: x => x.BotId, + principalTable: "Bots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "BotChannels", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + BotId = table.Column(type: "INTEGER", nullable: false), + TelegramId = table.Column(type: "INTEGER", nullable: false), + AccessHash = table.Column(type: "INTEGER", nullable: true), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Username = table.Column(type: "TEXT", maxLength: 100, nullable: true), + IsBroadcast = table.Column(type: "INTEGER", nullable: false), + MemberCount = table.Column(type: "INTEGER", nullable: false), + About = table.Column(type: "TEXT", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "TEXT", nullable: true), + SyncedAt = table.Column(type: "TEXT", nullable: false), + CategoryId = table.Column(type: "INTEGER", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_BotChannels", x => x.Id); + table.ForeignKey( + name: "FK_BotChannels_Bots_BotId", + column: x => x.BotId, + principalTable: "Bots", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BotChannels_BotChannelCategories_CategoryId", + column: x => x.CategoryId, + principalTable: "BotChannelCategories", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_BotChannelCategories_BotId_Name", + table: "BotChannelCategories", + columns: new[] { "BotId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_BotId_TelegramId", + table: "BotChannels", + columns: new[] { "BotId", "TelegramId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_CategoryId", + table: "BotChannels", + column: "CategoryId"); + + migrationBuilder.CreateIndex( + name: "IX_BotChannels_Username", + table: "BotChannels", + column: "Username"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 1d6e917..4c5a4f8 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -1,4 +1,4 @@ -// +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -15,7 +15,7 @@ namespace TelegramPanel.Data.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "8.0.0"); + modelBuilder.HasAnnotation("ProductVersion", "8.0.22"); modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => { @@ -64,16 +64,17 @@ namespace TelegramPanel.Data.Migrations .HasColumnType("TEXT"); b.Property("TelegramStatusDetails") + .HasMaxLength(2000) .HasColumnType("TEXT"); b.Property("TelegramStatusOk") .HasColumnType("INTEGER"); b.Property("TelegramStatusSummary") + .HasMaxLength(200) .HasColumnType("TEXT"); b.Property("TwoFactorPassword") - .HasMaxLength(200) .HasColumnType("TEXT"); b.Property("UserId") @@ -113,6 +114,7 @@ namespace TelegramPanel.Data.Migrations .HasColumnType("TEXT"); b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() .HasColumnType("INTEGER") .HasDefaultValue(false); @@ -129,6 +131,37 @@ namespace TelegramPanel.Data.Migrations 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") @@ -175,6 +208,153 @@ namespace TelegramPanel.Data.Migrations 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("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("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") @@ -191,7 +371,7 @@ namespace TelegramPanel.Data.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); - b.Property("CreatorAccountId") + b.Property("CreatorAccountId") .HasColumnType("INTEGER"); b.Property("GroupId") @@ -317,13 +497,60 @@ namespace TelegramPanel.Data.Migrations 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.Cascade) - .IsRequired(); + .OnDelete(DeleteBehavior.SetNull); b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group") .WithMany("Channels") @@ -348,6 +575,8 @@ namespace TelegramPanel.Data.Migrations modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => { + b.Navigation("AccountChannels"); + b.Navigation("Channels"); b.Navigation("Groups"); @@ -358,6 +587,26 @@ namespace TelegramPanel.Data.Migrations 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"); diff --git a/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs index 92dde68..862d895 100644 --- a/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BotChannelCategoryRepository.cs @@ -9,21 +9,19 @@ public class BotChannelCategoryRepository : Repository, IBot { } - public async Task> GetForBotAsync(int botId) + public async Task> GetAllOrderedAsync() { return await _dbSet - .Where(x => x.BotId == botId) .OrderBy(x => x.Name) .ToListAsync(); } - public async Task GetByNameAsync(int botId, string name) + public async Task GetByNameAsync(string name) { name = (name ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(name)) return null; - return await _dbSet.FirstOrDefaultAsync(x => x.BotId == botId && x.Name == name); + return await _dbSet.FirstOrDefaultAsync(x => x.Name == name); } } - diff --git a/src/TelegramPanel.Data/Repositories/BotChannelMemberRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelMemberRepository.cs new file mode 100644 index 0000000..33911ec --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/BotChannelMemberRepository.cs @@ -0,0 +1,52 @@ +using Microsoft.EntityFrameworkCore; +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public class BotChannelMemberRepository : Repository, IBotChannelMemberRepository +{ + public BotChannelMemberRepository(AppDbContext context) : base(context) + { + } + + public async Task GetAsync(int botId, int botChannelId) + { + return await _dbSet.FirstOrDefaultAsync(x => x.BotId == botId && x.BotChannelId == botChannelId); + } + + public async Task UpsertAsync(int botId, int botChannelId, DateTime syncedAt) + { + var existing = await GetAsync(botId, botChannelId); + if (existing != null) + { + existing.SyncedAt = syncedAt; + _dbSet.Update(existing); + await _context.SaveChangesAsync(); + return; + } + + await _dbSet.AddAsync(new BotChannelMember + { + BotId = botId, + BotChannelId = botChannelId, + SyncedAt = syncedAt + }); + await _context.SaveChangesAsync(); + } + + public async Task DeleteAsync(int botId, int botChannelId) + { + var existing = await GetAsync(botId, botChannelId); + if (existing == null) + return; + + _dbSet.Remove(existing); + await _context.SaveChangesAsync(); + } + + public async Task CountForChannelAsync(int botChannelId) + { + return await _dbSet.CountAsync(x => x.BotChannelId == botChannelId); + } +} + diff --git a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs index 698e621..bfa9935 100644 --- a/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BotChannelRepository.cs @@ -14,7 +14,7 @@ public class BotChannelRepository : Repository, IBotChannelRepositor var query = _dbSet .AsNoTracking() .Include(x => x.Category) - .Where(x => x.BotId == botId); + .Where(x => x.Members.Any(m => m.BotId == botId)); // 约定:categoryId = -1 表示“未分类”(CategoryId == null) if (categoryId.HasValue) @@ -44,23 +44,36 @@ public class BotChannelRepository : Repository, IBotChannelRepositor { return await _dbSet .Include(x => x.Category) - .FirstOrDefaultAsync(x => x.BotId == botId && x.TelegramId == telegramId); + .FirstOrDefaultAsync(x => x.TelegramId == telegramId && x.Members.Any(m => m.BotId == botId)); } public async Task> GetForBotAsync(int botId, int? categoryId = null) { var query = _dbSet .Include(x => x.Category) - .Where(x => x.BotId == botId); + .Where(x => x.Members.Any(m => m.BotId == botId)); + // 约定:categoryId = -1 表示“未分类”(CategoryId == null) if (categoryId.HasValue) - query = query.Where(x => x.CategoryId == categoryId.Value); + { + if (categoryId.Value == -1) + query = query.Where(x => x.CategoryId == null); + else + query = query.Where(x => x.CategoryId == categoryId.Value); + } return await query .OrderByDescending(x => x.SyncedAt) .ToListAsync(); } + public async Task GetGlobalByTelegramIdAsync(long telegramId) + { + return await _dbSet + .Include(x => x.Category) + .FirstOrDefaultAsync(x => x.TelegramId == telegramId); + } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryPagedAsync( int botId, int? categoryId, diff --git a/src/TelegramPanel.Data/Repositories/BotRepository.cs b/src/TelegramPanel.Data/Repositories/BotRepository.cs index b2a0b2c..26f09d8 100644 --- a/src/TelegramPanel.Data/Repositories/BotRepository.cs +++ b/src/TelegramPanel.Data/Repositories/BotRepository.cs @@ -31,10 +31,8 @@ public class BotRepository : Repository, IBotRepository { // 只用于列表显示统计信息,避免一次性加载全部频道详情 return await _dbSet - .Include(x => x.Channels) - .Include(x => x.Categories) + .Include(x => x.ChannelMembers) .OrderByDescending(x => x.CreatedAt) .ToListAsync(); } } - diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs index eb55f0f..0a82319 100644 --- a/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBotChannelCategoryRepository.cs @@ -4,7 +4,6 @@ namespace TelegramPanel.Data.Repositories; public interface IBotChannelCategoryRepository : IRepository { - Task> GetForBotAsync(int botId); - Task GetByNameAsync(int botId, string name); + Task> GetAllOrderedAsync(); + Task GetByNameAsync(string name); } - diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelMemberRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelMemberRepository.cs new file mode 100644 index 0000000..e6ace96 --- /dev/null +++ b/src/TelegramPanel.Data/Repositories/IBotChannelMemberRepository.cs @@ -0,0 +1,12 @@ +using TelegramPanel.Data.Entities; + +namespace TelegramPanel.Data.Repositories; + +public interface IBotChannelMemberRepository : IRepository +{ + Task GetAsync(int botId, int botChannelId); + Task UpsertAsync(int botId, int botChannelId, DateTime syncedAt); + Task DeleteAsync(int botId, int botChannelId); + Task CountForChannelAsync(int botChannelId); +} + diff --git a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs index cb88f97..af766da 100644 --- a/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IBotChannelRepository.cs @@ -6,6 +6,7 @@ public interface IBotChannelRepository : IRepository { Task GetByTelegramIdAsync(int botId, long telegramId); Task> GetForBotAsync(int botId, int? categoryId = null); + Task GetGlobalByTelegramIdAsync(long telegramId); Task<(IReadOnlyList Items, int TotalCount)> QueryPagedAsync( int botId, diff --git a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs index fcdc98c..e3a0bca 100644 --- a/src/TelegramPanel.Data/ServiceCollectionExtensions.cs +++ b/src/TelegramPanel.Data/ServiceCollectionExtensions.cs @@ -30,6 +30,7 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(typeof(IRepository<>), typeof(Repository<>)); return services; diff --git a/src/TelegramPanel.Web/Components/Dialogs/BotChannelCategoryDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BotChannelCategoryDialog.razor index b67efc4..7ab9591 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BotChannelCategoryDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BotChannelCategoryDialog.razor @@ -31,7 +31,6 @@ @code { [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; - [Parameter] public int BotId { get; set; } [Parameter] public BotChannelCategory? Category { get; set; } private bool saving; @@ -71,7 +70,7 @@ if (Category == null) { // 新增模式:使用现有的 CreateCategoryAsync 方法 - var category = await BotManagement.CreateCategoryAsync(BotId, name, description); + var category = await BotManagement.CreateCategoryAsync(name, description); Snackbar.Add("分类创建成功", Severity.Success); MudDialog.Close(DialogResult.Ok(category)); } diff --git a/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor index a1b2903..3988bfe 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/BotChannelEditDialog.razor @@ -124,9 +124,8 @@ var username = chatInfo?.Username ?? dbChannel?.Username; var memberCount = chatInfo?.MemberCount ?? dbChannel?.MemberCount ?? 0; - await BotManagement.UpsertChannelAsync(new BotChannel + await BotManagement.UpsertChannelAsync(BotId, new BotChannel { - BotId = BotId, TelegramId = ChannelTelegramId, Title = title, Username = username, diff --git a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor index 1157541..1486ac0 100644 --- a/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor +++ b/src/TelegramPanel.Web/Components/Pages/BotChannelsHome.razor @@ -252,7 +252,7 @@ return; } - categories = (await BotManagement.GetCategoriesAsync(selectedBotId)).ToList(); + categories = (await BotManagement.GetCategoriesAsync()).ToList(); await ReloadTable(); } catch (Exception ex) @@ -364,10 +364,7 @@ if (selectedBotId <= 0) return; - var parameters = new DialogParameters - { - ["BotId"] = selectedBotId - }; + var parameters = new DialogParameters(); var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; var dialog = DialogService.Show("新建分类", parameters, options); var result = await dialog.Result; diff --git a/src/TelegramPanel.Web/Components/Pages/Bots.razor b/src/TelegramPanel.Web/Components/Pages/Bots.razor index ef0f16c..989f96e 100644 --- a/src/TelegramPanel.Web/Components/Pages/Bots.razor +++ b/src/TelegramPanel.Web/Components/Pages/Bots.razor @@ -40,8 +40,7 @@ 名称 用户名 - 频道数 - 分类数 + 可管理聊天数 最后同步 状态 操作 @@ -49,8 +48,7 @@ @context.Name @(!string.IsNullOrWhiteSpace(context.Username) ? $"@{context.Username}" : "-") - @context.Channels.Count(x => x.IsBroadcast) - @context.Categories.Count + @context.ChannelMembers.Count @@ -151,7 +149,7 @@ { bool? result = await DialogService.ShowMessageBox( "确认删除", - $"确定要删除机器人 {bot.Name} 吗?该机器人加入的频道与分类也会一并删除。", + $"确定要删除机器人 {bot.Name} 吗?该机器人加入的频道关联也会一并删除。", yesText: "删除", cancelText: "取消"); if (result != true)