diff --git a/src/TelegramPanel.Core/Services/ChannelManagementService.cs b/src/TelegramPanel.Core/Services/ChannelManagementService.cs index 15e901c..0d9b149 100644 --- a/src/TelegramPanel.Core/Services/ChannelManagementService.cs +++ b/src/TelegramPanel.Core/Services/ChannelManagementService.cs @@ -184,7 +184,30 @@ public class ChannelManagementService public async Task DeleteStaleAccountChannelsAsync(int accountId, IReadOnlyCollection keepChannelIds) { - await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds); + var keepSet = keepChannelIds.Count == 0 + ? new HashSet() + : keepChannelIds.ToHashSet(); + + var staleChannelIds = (await _accountChannelRepository.GetByAccountAsync(accountId)) + .Where(x => !keepSet.Contains(x.ChannelId)) + .Select(x => x.ChannelId) + .Distinct() + .ToList(); + + foreach (var channelId in staleChannelIds) + await RemoveAccountChannelAsync(channelId, accountId); + + var staleChannelIdSet = staleChannelIds.ToHashSet(); + var creatorOnlyChannelIds = (await _channelRepository.FindAsync(x => x.CreatorAccountId == accountId)) + .Select(x => x.Id) + .Where(id => !keepSet.Contains(id) && !staleChannelIdSet.Contains(id)) + .Distinct() + .ToList(); + + foreach (var channelId in creatorOnlyChannelIds) + await DetachCreatorFromChannelAsync(channelId, accountId); + + await _channelRepository.DeleteOrphanedAsync(); } public async Task> GetAccountChannelMembershipsAsync(int accountId, CancellationToken cancellationToken = default) @@ -224,6 +247,24 @@ public class ChannelManagementService await _channelRepository.UpdateAsync(channel); } + private async Task DetachCreatorFromChannelAsync(int channelId, int accountId) + { + var channel = await _channelRepository.GetByIdAsync(channelId); + if (channel == null || channel.CreatorAccountId != accountId) + return; + + channel.CreatorAccountId = null; + + if (channel.AccountChannels.Count == 0) + { + await _channelRepository.DeleteAsync(channel); + return; + } + + channel.SyncedAt = DateTime.UtcNow; + await _channelRepository.UpdateAsync(channel); + } + /// /// 解析频道操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/GroupManagementService.cs b/src/TelegramPanel.Core/Services/GroupManagementService.cs index 4a2f7f6..423fbda 100644 --- a/src/TelegramPanel.Core/Services/GroupManagementService.cs +++ b/src/TelegramPanel.Core/Services/GroupManagementService.cs @@ -127,7 +127,30 @@ public class GroupManagementService public async Task DeleteStaleAccountGroupsAsync(int accountId, IReadOnlyCollection keepGroupIds) { - await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds); + var keepSet = keepGroupIds.Count == 0 + ? new HashSet() + : keepGroupIds.ToHashSet(); + + var staleGroupIds = (await _accountGroupRepository.GetByAccountAsync(accountId)) + .Where(x => !keepSet.Contains(x.GroupId)) + .Select(x => x.GroupId) + .Distinct() + .ToList(); + + foreach (var groupId in staleGroupIds) + await RemoveAccountGroupAsync(groupId, accountId); + + var staleGroupIdSet = staleGroupIds.ToHashSet(); + var creatorOnlyGroupIds = (await _groupRepository.FindAsync(x => x.CreatorAccountId == accountId)) + .Select(x => x.Id) + .Where(id => !keepSet.Contains(id) && !staleGroupIdSet.Contains(id)) + .Distinct() + .ToList(); + + foreach (var groupId in creatorOnlyGroupIds) + await DetachCreatorFromGroupAsync(groupId, accountId); + + await _groupRepository.DeleteOrphanedAsync(); } public async Task> GetAccountGroupMembershipsAsync(int accountId, CancellationToken cancellationToken = default) @@ -167,6 +190,24 @@ public class GroupManagementService await _groupRepository.UpdateAsync(group); } + private async Task DetachCreatorFromGroupAsync(int groupId, int accountId) + { + var group = await _groupRepository.GetByIdAsync(groupId); + if (group == null || group.CreatorAccountId != accountId) + return; + + group.CreatorAccountId = null; + + if (group.AccountGroups.Count == 0) + { + await _groupRepository.DeleteAsync(group); + return; + } + + group.SyncedAt = DateTime.UtcNow; + await _groupRepository.UpdateAsync(group); + } + /// /// 解析群组操作的执行账号: /// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。 diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs index 52ad472..8d2e55e 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountTelegramToolsService.cs @@ -88,6 +88,7 @@ public class AccountTelegramToolsService if (account != null) { profile.ApplyTo(account); + await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken); } var summary = "正常"; @@ -246,6 +247,127 @@ public class AccountTelegramToolsService .ToList(); } + public async Task EnsureEstimatedRegistrationAsync(int accountId, CancellationToken cancellationToken = default) + { + try + { + var account = await _accountManagement.GetAccountAsync(accountId); + if (account == null) + return; + + if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue) + return; + + var client = await GetOrCreateConnectedClientAsync(accountId, cancellationToken); + await TryPopulateEstimatedRegistrationAsync(account, client, accountId, cancellationToken); + + if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue) + await _accountManagement.UpdateAccountAsync(account); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "EnsureEstimatedRegistrationAsync skipped for account {AccountId}", accountId); + } + } + + private async Task TryPopulateEstimatedRegistrationAsync( + Account account, + Client client, + int accountId, + CancellationToken cancellationToken) + { + if (account.EstimatedRegistrationAt.HasValue || account.EstimatedRegistrationCheckedAtUtc.HasValue) + return; + + var (checkedSuccessfully, estimatedAtUtc) = await TryGetEstimatedRegistrationFromSystemMessagesAsync(client, accountId, cancellationToken); + if (!checkedSuccessfully) + return; + + if (estimatedAtUtc.HasValue) + account.EstimatedRegistrationAt = estimatedAtUtc.Value; + + account.EstimatedRegistrationCheckedAtUtc = DateTime.UtcNow; + } + + private async Task<(bool CheckedSuccessfully, DateTime? EstimatedAtUtc)> TryGetEstimatedRegistrationFromSystemMessagesAsync( + Client client, + int accountId, + CancellationToken cancellationToken) + { + try + { + var peer = await TryResolveSystemPeerAsync(client); + if (peer == null) + return (true, null); + + const int pageSize = 100; + const int maxPages = 200; + var offsetId = 0; + DateTime? earliest = null; + + for (var page = 0; page < maxPages; page++) + { + cancellationToken.ThrowIfCancellationRequested(); + + var history = await ExecuteTelegramRequestAsync( + accountId, + "读取 777000 系统通知历史", + () => client.Messages_GetHistory(peer, offset_id: offsetId, limit: pageSize), + cancellationToken, + resetClientOnTimeout: true); + + if (history.Messages == null || history.Messages.Length == 0) + break; + + foreach (var msgBase in history.Messages) + { + if (msgBase is not Message message) + continue; + + if (string.IsNullOrWhiteSpace(message.message)) + continue; + + var messageUtc = message.Date.ToUniversalTime(); + if (!earliest.HasValue || messageUtc < earliest.Value) + earliest = messageUtc; + } + + var nextOffsetId = history.Messages + .Select(GetTelegramMessageId) + .Where(id => id > 0) + .DefaultIfEmpty(0) + .Min(); + + if (nextOffsetId <= 0 || nextOffsetId == offsetId || history.Messages.Length < pageSize) + break; + + offsetId = nextOffsetId; + } + + return (true, earliest); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to estimate registration time from 777000 for account {AccountId}", accountId); + return (false, null); + } + } + + private static int GetTelegramMessageId(MessageBase msgBase) => msgBase switch + { + Message message => message.id, + MessageService service => service.id, + _ => 0 + }; + public async Task> GetAuthorizationsAsync(int accountId) { var client = await GetOrCreateConnectedClientAsync(accountId); diff --git a/src/TelegramPanel.Data/Entities/Account.cs b/src/TelegramPanel.Data/Entities/Account.cs index 7decde3..982de3b 100644 --- a/src/TelegramPanel.Data/Entities/Account.cs +++ b/src/TelegramPanel.Data/Entities/Account.cs @@ -38,6 +38,16 @@ public class Account public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime LastSyncAt { get; set; } = DateTime.UtcNow; + /// + /// 通过 777000 系统通知最早消息时间估算的注册时间(非百分百准确) + /// + public DateTime? EstimatedRegistrationAt { get; set; } + + /// + /// 最近一次尝试估算注册时间的时间(UTC) + /// + public DateTime? EstimatedRegistrationCheckedAtUtc { get; set; } + /// /// 最后一次登录 Telegram 的时间(UTC),用于风控检查 /// diff --git a/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs new file mode 100644 index 0000000..625d15c --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.Designer.cs @@ -0,0 +1,745 @@ +// +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("20260309051752_AddAccountEstimatedRegistrationFields")] + partial class AddAccountEstimatedRegistrationFields + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "8.0.24"); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ApiId") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("Nickname") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Phone") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("SessionPath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("TelegramStatusDetails") + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("TelegramStatusOk") + .HasColumnType("INTEGER"); + + b.Property("TelegramStatusSummary") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("TwoFactorPassword") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ExcludeFromOperations") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("AccountCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("AccountId", "ChannelId") + .IsUnique(); + + b.HasIndex("ChannelId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsAdmin") + .HasColumnType("INTEGER"); + + b.Property("IsCreator") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("AccountId", "GroupId") + .IsUnique(); + + b.HasIndex("GroupId", "IsCreator", "IsAdmin"); + + b.ToTable("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Completed") + .HasColumnType("INTEGER"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("Config") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Failed") + .HasColumnType("INTEGER"); + + b.Property("StartedAt") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Status"); + + b.ToTable("BatchTasks"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSyncAt") + .HasColumnType("TEXT"); + + b.Property("LastUpdateId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Bots"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ChannelStatusCheckedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ChannelStatusError") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelStatusOk") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("ChannelStatusOk"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("BotChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("BotChannelCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BotChannelId") + .HasColumnType("INTEGER"); + + b.Property("BotId") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BotChannelId"); + + b.HasIndex("BotId"); + + b.HasIndex("BotId", "BotChannelId") + .IsUnique(); + + b.ToTable("BotChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("INTEGER"); + + b.Property("IsBroadcast") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("GroupId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("ChannelGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("About") + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("AccessHash") + .HasColumnType("INTEGER"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CreatorAccountId") + .HasColumnType("INTEGER"); + + b.Property("MemberCount") + .HasColumnType("INTEGER"); + + b.Property("SyncedAt") + .HasColumnType("TEXT"); + + b.Property("TelegramId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Username") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CategoryId"); + + b.HasIndex("CreatorAccountId"); + + b.HasIndex("SyncedAt"); + + b.HasIndex("TelegramId") + .IsUnique(); + + b.HasIndex("Username"); + + b.ToTable("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("GroupCategories"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category") + .WithMany("Accounts") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountChannels") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel") + .WithMany("AccountChannels") + .HasForeignKey("ChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Channel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "Account") + .WithMany("AccountGroups") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Group", "Group") + .WithMany("AccountGroups") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category") + .WithMany("Channels") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b => + { + b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel") + .WithMany("Members") + .HasForeignKey("BotChannelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot") + .WithMany("ChannelMembers") + .HasForeignKey("BotId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Bot"); + + b.Navigation("BotChannel"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Channels") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group") + .WithMany("Channels") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("CreatorAccount"); + + b.Navigation("Group"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.HasOne("TelegramPanel.Data.Entities.GroupCategory", "Category") + .WithMany("Groups") + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount") + .WithMany("Groups") + .HasForeignKey("CreatorAccountId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Category"); + + b.Navigation("CreatorAccount"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b => + { + b.Navigation("AccountChannels"); + + b.Navigation("AccountGroups"); + + b.Navigation("Channels"); + + b.Navigation("Groups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b => + { + b.Navigation("Accounts"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b => + { + b.Navigation("ChannelMembers"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b => + { + b.Navigation("AccountChannels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b => + { + b.Navigation("Channels"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b => + { + b.Navigation("AccountGroups"); + }); + + modelBuilder.Entity("TelegramPanel.Data.Entities.GroupCategory", b => + { + b.Navigation("Groups"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs new file mode 100644 index 0000000..ed07fa1 --- /dev/null +++ b/src/TelegramPanel.Data/Migrations/20260309051752_AddAccountEstimatedRegistrationFields.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TelegramPanel.Data.Migrations +{ + /// + public partial class AddAccountEstimatedRegistrationFields : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EstimatedRegistrationAt", + table: "Accounts", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "EstimatedRegistrationCheckedAtUtc", + table: "Accounts", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EstimatedRegistrationAt", + table: "Accounts"); + + migrationBuilder.DropColumn( + name: "EstimatedRegistrationCheckedAtUtc", + table: "Accounts"); + } + } +} diff --git a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs index 9ce8981..826e1c4 100644 --- a/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/TelegramPanel.Data/Migrations/AppDbContextModelSnapshot.cs @@ -37,6 +37,12 @@ namespace TelegramPanel.Data.Migrations b.Property("CreatedAt") .HasColumnType("TEXT"); + b.Property("EstimatedRegistrationAt") + .HasColumnType("TEXT"); + + b.Property("EstimatedRegistrationCheckedAtUtc") + .HasColumnType("TEXT"); + b.Property("IsActive") .HasColumnType("INTEGER"); diff --git a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs index 97dd136..6c5cd0f 100644 --- a/src/TelegramPanel.Data/Repositories/ChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/ChannelRepository.cs @@ -183,6 +183,20 @@ public class ChannelRepository : Repository, IChannelRepository .ToListAsync(); } + public async Task DeleteOrphanedAsync(CancellationToken cancellationToken = default) + { + var orphanedChannels = await _dbSet + .Where(c => c.CreatorAccountId == null && !c.AccountChannels.Any()) + .ToListAsync(cancellationToken); + + if (orphanedChannels.Count == 0) + return 0; + + _dbSet.RemoveRange(orphanedChannels); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + return orphanedChannels.Count; + } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, int? groupId, diff --git a/src/TelegramPanel.Data/Repositories/GroupRepository.cs b/src/TelegramPanel.Data/Repositories/GroupRepository.cs index 9149b75..edc269c 100644 --- a/src/TelegramPanel.Data/Repositories/GroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/GroupRepository.cs @@ -131,6 +131,20 @@ public class GroupRepository : Repository, IGroupRepository .ToListAsync(); } + public async Task DeleteOrphanedAsync(CancellationToken cancellationToken = default) + { + var orphanedGroups = await _dbSet + .Where(g => g.CreatorAccountId == null && !g.AccountGroups.Any()) + .ToListAsync(cancellationToken); + + if (orphanedGroups.Count == 0) + return 0; + + _dbSet.RemoveRange(orphanedGroups); + await SaveChangesWithSqliteLockRetryAsync(cancellationToken); + return orphanedGroups.Count; + } + public async Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, int? categoryId, diff --git a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs index 5b79c73..773c1ed 100644 --- a/src/TelegramPanel.Data/Repositories/IChannelRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IChannelRepository.cs @@ -13,6 +13,7 @@ public interface IChannelRepository : IRepository Task> GetForAccountAsync(int accountId, bool includeNonCreator); Task> GetByGroupAsync(int groupId); Task> GetBroadcastChannelsAsync(); + Task DeleteOrphanedAsync(CancellationToken cancellationToken = default); Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, diff --git a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs index e5f60f0..5471cca 100644 --- a/src/TelegramPanel.Data/Repositories/IGroupRepository.cs +++ b/src/TelegramPanel.Data/Repositories/IGroupRepository.cs @@ -9,6 +9,7 @@ public interface IGroupRepository : IRepository { Task GetByTelegramIdAsync(long telegramId); Task> GetByCreatorAccountAsync(int accountId); + Task DeleteOrphanedAsync(CancellationToken cancellationToken = default); Task<(IReadOnlyList Items, int TotalCount)> QueryForViewPagedAsync( int accountId, int? categoryId, diff --git a/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor index 0ca3e70..2f4eea8 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/AccountDetailsDialog.razor @@ -17,7 +17,8 @@ else { - + + @@ -86,6 +87,11 @@ return account == null ? "-" : TimeZone.Format(account.CreatedAt, "yyyy-MM-dd HH:mm"); } + private string FormatEstimatedRegistrationAt() + { + return account == null ? "-" : TimeZone.Format(account.EstimatedRegistrationAt, "yyyy-MM-dd HH:mm", "-"); + } + private string DisplayPassword() { if (account == null) diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 82baa4c..4470f7c 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -115,7 +115,7 @@ 频道/群组 状态 Telegram 状态 - 注册时间 + 注册时间(估算,非百分百正确) 最后数据同步 操作 @@ -156,7 +156,7 @@ 未检测 } - + diff --git a/src/TelegramPanel.Web/Services/DataSyncService.cs b/src/TelegramPanel.Web/Services/DataSyncService.cs index c1eb95f..9c9dcb2 100644 --- a/src/TelegramPanel.Web/Services/DataSyncService.cs +++ b/src/TelegramPanel.Web/Services/DataSyncService.cs @@ -324,6 +324,8 @@ public class DataSyncService await _groupManagement.DeleteStaleAccountGroupsAsync(account.Id, keepGroupIds); + await _telegramTools.EnsureEstimatedRegistrationAsync(account.Id, cancellationToken); + await _accountManagement.UpdateLastSyncTimeAsync(account.Id); } catch (Exception ex)