fix: cleanup stale sync data and estimate account registration

This commit is contained in:
meoacgx committed 2026-03-09 13:24:19 +08:00
1 parent efebe9e66b
commit 3cdc458f27
14 files changed
+1047 -5

No files matched your search

@@ -184,7 +184,30 @@ public class ChannelManagementService
public async Task DeleteStaleAccountChannelsAsync(int accountId, IReadOnlyCollection<int> keepChannelIds)
{
await _accountChannelRepository.DeleteForAccountExceptAsync(accountId, keepChannelIds);
var keepSet = keepChannelIds.Count == 0
? new HashSet<int>()
: 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<IReadOnlyList<AccountChannel>> 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);
}
/// <summary>
/// 解析频道操作的执行账号:
/// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。
@@ -127,7 +127,30 @@ public class GroupManagementService
public async Task DeleteStaleAccountGroupsAsync(int accountId, IReadOnlyCollection<int> keepGroupIds)
{
await _accountGroupRepository.DeleteForAccountExceptAsync(accountId, keepGroupIds);
var keepSet = keepGroupIds.Count == 0
? new HashSet<int>()
: 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<IReadOnlyList<AccountGroup>> 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);
}
/// <summary>
/// 解析群组操作的执行账号:
/// 优先使用 preferredAccountId,其次 CreatorAccountId,否则从关联表中挑选一个管理员账号。
@@ -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<IReadOnlyList<TelegramAuthorizationInfo>> GetAuthorizationsAsync(int accountId)
{
var client = await GetOrCreateConnectedClientAsync(accountId);
@@ -38,6 +38,16 @@ public class Account
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime LastSyncAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 通过 777000 系统通知最早消息时间估算的注册时间(非百分百准确)
/// </summary>
public DateTime? EstimatedRegistrationAt { get; set; }
/// <summary>
/// 最近一次尝试估算注册时间的时间(UTC)
/// </summary>
public DateTime? EstimatedRegistrationCheckedAtUtc { get; set; }
/// <summary>
/// 最后一次登录 Telegram 的时间(UTC),用于风控检查
/// </summary>
@@ -0,0 +1,745 @@
// <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("20260309051752_AddAccountEstimatedRegistrationFields")]
partial class AddAccountEstimatedRegistrationFields
{
/// <inheritdoc />
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<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<DateTime?>("EstimatedRegistrationAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc")
.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.HasIndex("ChannelId", "IsCreator", "IsAdmin");
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("GroupId")
.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("GroupId");
b.HasIndex("AccountId", "GroupId")
.IsUnique();
b.HasIndex("GroupId", "IsCreator", "IsAdmin");
b.ToTable("AccountGroups");
});
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("SyncedAt");
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<int?>("CategoryId")
.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("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<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("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
}
}
}
@@ -0,0 +1,39 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddAccountEstimatedRegistrationFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedRegistrationAt",
table: "Accounts",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "EstimatedRegistrationCheckedAtUtc",
table: "Accounts",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EstimatedRegistrationAt",
table: "Accounts");
migrationBuilder.DropColumn(
name: "EstimatedRegistrationCheckedAtUtc",
table: "Accounts");
}
}
}
@@ -37,6 +37,12 @@ namespace TelegramPanel.Data.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationAt")
.HasColumnType("TEXT");
b.Property<DateTime?>("EstimatedRegistrationCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
@@ -183,6 +183,20 @@ public class ChannelRepository : Repository<Channel>, IChannelRepository
.ToListAsync();
}
public async Task<int> 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<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? groupId,
@@ -131,6 +131,20 @@ public class GroupRepository : Repository<Group>, IGroupRepository
.ToListAsync();
}
public async Task<int> 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<Group> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? categoryId,
@@ -13,6 +13,7 @@ public interface IChannelRepository : IRepository<Channel>
Task<IEnumerable<Channel>> GetForAccountAsync(int accountId, bool includeNonCreator);
Task<IEnumerable<Channel>> GetByGroupAsync(int groupId);
Task<IEnumerable<Channel>> GetBroadcastChannelsAsync();
Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Channel> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
@@ -9,6 +9,7 @@ public interface IGroupRepository : IRepository<Group>
{
Task<Group?> GetByTelegramIdAsync(long telegramId);
Task<IEnumerable<Group>> GetByCreatorAccountAsync(int accountId);
Task<int> DeleteOrphanedAsync(CancellationToken cancellationToken = default);
Task<(IReadOnlyList<Group> Items, int TotalCount)> QueryForViewPagedAsync(
int accountId,
int? categoryId,
@@ -17,7 +17,8 @@
else
{
<MudStack Spacing="3">
<MudTextField Value="@FormatCreatedAt()" Label="创建时间" ReadOnly="true" Variant="Variant.Outlined" />
<MudTextField Value="@FormatEstimatedRegistrationAt()" Label="注册时间(估算,非百分百正确)" ReadOnly="true" Variant="Variant.Outlined" />
<MudTextField Value="@FormatCreatedAt()" Label="导入时间" ReadOnly="true" Variant="Variant.Outlined" />
<MudTextField Value="@account.SessionPath" Label="Session 路径" ReadOnly="true" Variant="Variant.Outlined" />
<MudDivider Class="my-2" />
@@ -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)
@@ -115,7 +115,7 @@
<MudTh>频道/群组</MudTh>
<MudTh>状态</MudTh>
<MudTh>Telegram 状态</MudTh>
<MudTh>注册时间</MudTh>
<MudTh>注册时间(估算,非百分百正确)</MudTh>
<MudTh>最后数据同步</MudTh>
<MudTh Style="min-width: 180px;">操作</MudTh>
</HeaderContent>
@@ -156,7 +156,7 @@
<MudChip T="string" Size="Size.Small" Color="Color.Default">未检测</MudChip>
}
</MudTd>
<MudTd DataLabel="注册时间"><PanelTime Value="context.CreatedAt" /></MudTd>
<MudTd DataLabel="注册时间(估算,非百分百正确)"><PanelTime Value="context.EstimatedRegistrationAt" EmptyText="-" /></MudTd>
<MudTd DataLabel="最后数据同步"><PanelTime Value="context.LastSyncAt" /></MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
@@ -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)