release(main): 完善 Bot 频道异常治理(删除+状态检测+异常筛选)

本次版本主要更新:

- ✨ Bot 频道删除能力补齐:新增单个删除与批量删除,支持清理 Bot 被踢出、频道被封禁后的无效记录,避免脏数据长期保留。
- ✨ 频道状态检测能力:新增“检测状态(已选/单个)”,检测流程为 sendMessage 后立即 deleteMessage;发送失败自动标记异常并记录失败原因。
- ✨ 列表状态可视化与筛选:Bot 频道列表新增“频道状态”(正常/异常/未检测)展示,支持按异常状态筛选,便于快速定位不可用频道。
- ⚡ 数据层落库与查询增强:BotChannel 新增状态字段(ChannelStatusOk/ChannelStatusCheckedAtUtc/ChannelStatusError),分页查询新增状态过滤参数。
- 🧱 数据库迁移补齐:新增 AddBotChannelStatusFields 迁移与索引,保障旧环境升级后可直接使用状态筛选。
- 🐛 可维护性提升:检测结果持久化,刷新页面不丢状态;批量检测/删除增加结果摘要,异常排查路径更清晰。
This commit is contained in:
meoacgx committed 2026-03-03 01:49:26 +08:00
commit 34cab69c07
17 files changed
+1787 -63

No files matched your search

+45
View File
@@ -257,6 +257,51 @@ public sealed class MyHandler : IModuleTaskHandler
}
```
## 账号导出下载(Telethon / Tdata)
如果模块需要“下载某个账号的数据包”,建议优先使用宿主服务直接生成 Zip(同进程内调用),避免绕 HTTP 鉴权与 Cookie。
### 推荐方式:模块内直接调用导出服务
可注入:
- `TelegramPanel.Web.Services.AccountExportService`
- `TelegramPanel.Core.Services.AccountManagementService`
核心调用链:
1. 先通过 `AccountManagementService` 获取目标账号(或账号列表)
2. 调用 `AccountExportService.BuildAccountsZipAsync(accounts, ct, format)`
3. 将 `byte[]` 按模块自己的场景返回/落盘/上传
其中 `format`:
- `AccountExportFormat.Telethon`:导出 `.json + .session (+2fa.txt)`
- `AccountExportFormat.Tdata`:在以上基础上额外导出 `tdata/`
### HTTP 方式(备选)
宿主现有下载接口:
- `GET /downloads/accounts.zip`
- Query:
- `ids=1,2,3`(可选,不传则导出全部)
- `format=telethon|tdata`(不传默认 `telethon`)
- `ts=<timestamp>`(可选,建议带上,避免浏览器缓存旧包)
注意:
- 若开启后台登录,接口受登录态保护(需带管理端 Cookie)
- 响应已设置 `no-store/no-cache`,但调用方仍建议加 `ts`
### Tdata 导出的实现要点(后续扩展必须保持)
1. `session -> telethon string` 时必须保留 Base64 padding(尾部 `=`)
2. `telethon string -> tdata` 时必须注入 `session.self.userId`
3. 生成 `telethon string` 时要优先选择“已授权 DCSession”(不是任意 DC)
否则会出现“包结构看似正常,但 Telegram Desktop 仍要求重新登录”。
## UI 模块项目模板(Razor 组件)
如果你的模块需要提供页面(`IModuleUiProvider.GetPages`),推荐把模块做成 `Microsoft.NET.Sdk.Razor` 项目(类似 Razor Class Library),例如:
@@ -273,6 +273,7 @@ public class BotManagementService
int botId,
int? categoryId,
string? search,
int statusFilter,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
@@ -282,6 +283,7 @@ public class BotManagementService
categoryId: categoryId,
broadcastOnly: true,
search: search,
statusFilter: statusFilter,
pageIndex: pageIndex,
pageSize: pageSize,
cancellationToken: cancellationToken);
@@ -348,6 +350,23 @@ public class BotManagementService
await _botChannelRepository.DeleteAsync(ch);
}
public async Task UpdateChannelStatusAsync(int botId, long telegramId, bool ok, string? error, DateTime checkedAtUtc)
{
var ch = await _botChannelRepository.GetByTelegramIdAsync(botId, telegramId);
if (ch == null)
return;
ch.ChannelStatusOk = ok;
ch.ChannelStatusCheckedAtUtc = checkedAtUtc;
ch.ChannelStatusError = ok
? null
: string.IsNullOrWhiteSpace(error)
? "检测失败"
: error.Trim()[..Math.Min(error.Trim().Length, 500)];
await _botChannelRepository.UpdateAsync(ch);
}
public async Task SetChannelCategoryAsync(int botChannelId, int? categoryId)
{
var ch = await _botChannelRepository.GetByIdAsync(botChannelId);
@@ -37,6 +37,7 @@ public class BotTelegramService
}
public sealed record BotChannelSyncResult(int AppliedUpdates, int RemovedStale);
public sealed record ChannelStatusCheckResult(int SuccessCount, int FailedCount, int TotalCount, IReadOnlyDictionary<long, string> Failures);
/// <summary>
/// 手动同步(新增 + 清理):
@@ -632,6 +633,132 @@ public class BotTelegramService
return map;
}
/// <summary>
/// 批量检测 Bot 在频道中的可操作状态:
/// 通过 sendMessage 发送探测消息并立即 deleteMessage;发送失败则记为异常。
/// </summary>
public async Task<ChannelStatusCheckResult> CheckChannelsStatusAsync(
int botId,
IReadOnlyList<long> channelTelegramIds,
CancellationToken cancellationToken)
{
var bot = await _botManagement.GetBotAsync(botId)
?? throw new InvalidOperationException($"机器人不存在:{botId}");
if (!bot.IsActive)
throw new InvalidOperationException("该机器人已停用");
var ids = channelTelegramIds
.Where(x => x != 0)
.Distinct()
.ToList();
if (ids.Count == 0)
return new ChannelStatusCheckResult(0, 0, 0, new Dictionary<long, string>());
var failures = new Dictionary<long, string>();
var delayMs = ReadInt(_configuration, "Telegram:DefaultDelayMs", 1000);
if (delayMs < 0) delayMs = 0;
if (delayMs > 10000) delayMs = 10000;
var maxRetries = ReadInt(_configuration, "Telegram:MaxRetries", 1);
if (maxRetries < 0) maxRetries = 0;
if (maxRetries > 5) maxRetries = 5;
var ok = 0;
for (var index = 0; index < ids.Count; index++)
{
var chatId = ids[index];
cancellationToken.ThrowIfCancellationRequested();
var attempt = 0;
while (true)
{
try
{
await ProbeChannelBySendDeleteAsync(bot.Token, chatId, cancellationToken);
await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: true, error: null, checkedAtUtc: DateTime.UtcNow);
ok++;
break;
}
catch (InvalidOperationException ex) when (TryGetRetryAfterSeconds(ex.Message, out var seconds))
{
attempt++;
if (attempt > maxRetries)
{
var msg = string.IsNullOrWhiteSpace(ex.Message) ? "检测失败(限流重试超限)" : ex.Message.Trim();
failures[chatId] = msg;
await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: false, error: msg, checkedAtUtc: DateTime.UtcNow);
_logger.LogWarning(
"CheckChannelsStatus retry exceeded: bot={BotId} chat={ChatId} retryAfter={RetryAfter}s",
botId,
chatId,
seconds);
break;
}
_logger.LogWarning(
"CheckChannelsStatus hit rate limit, retry after {Seconds}s (attempt {Attempt}/{Max}): bot={BotId} chat={ChatId}",
seconds,
attempt,
maxRetries,
botId,
chatId);
await Task.Delay(TimeSpan.FromSeconds(seconds), cancellationToken);
}
catch (Exception ex)
{
var msg = string.IsNullOrWhiteSpace(ex.Message) ? "检测失败" : ex.Message.Trim();
failures[chatId] = msg;
await _botManagement.UpdateChannelStatusAsync(bot.Id, chatId, ok: false, error: msg, checkedAtUtc: DateTime.UtcNow);
_logger.LogWarning(ex, "CheckChannelsStatus failed: bot={BotId} chat={ChatId}", botId, chatId);
break;
}
}
if (delayMs > 0 && index < ids.Count - 1)
{
var jitter = Random.Shared.Next(0, Math.Min(300, delayMs + 1));
await Task.Delay(delayMs + jitter, cancellationToken);
}
}
return new ChannelStatusCheckResult(ok, failures.Count, ids.Count, failures);
}
private async Task ProbeChannelBySendDeleteAsync(string token, long chatId, CancellationToken cancellationToken)
{
var probeText = $"频道状态检测 {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}";
var sendResult = await _api.CallAsync(token, "sendMessage", new Dictionary<string, string?>
{
["chat_id"] = chatId.ToString(),
["text"] = probeText,
["disable_notification"] = "true"
}, cancellationToken);
if (sendResult.ValueKind != JsonValueKind.Object
|| !sendResult.TryGetProperty("message_id", out var messageIdEl)
|| !messageIdEl.TryGetInt64(out var messageId))
{
throw new InvalidOperationException("Bot API sendMessage 返回格式异常,无法完成频道检测");
}
try
{
await _api.CallAsync(token, "deleteMessage", new Dictionary<string, string?>
{
["chat_id"] = chatId.ToString(),
["message_id"] = messageId.ToString()
}, cancellationToken);
}
catch (Exception ex)
{
// 删除失败不判定为“频道异常”,但记录日志便于排查(例如 Bot 缺少删消息权限)。
_logger.LogWarning(ex, "Probe deleteMessage failed: chat={ChatId}", chatId);
}
}
private static bool TryParseChatMemberUpdate(JsonElement myChatMember, out BotApiChat chat, out string status)
{
chat = default;
@@ -19,6 +19,12 @@ public static class SessionDataConverter
public static SessionConvertResult Fail(string? reason) => new(false, string.IsNullOrWhiteSpace(reason) ? "未知原因" : reason.Trim());
}
public readonly record struct TelethonStringSessionResult(bool Ok, string? SessionString, string? Reason)
{
public static TelethonStringSessionResult Success(string sessionString) => new(true, sessionString, null);
public static TelethonStringSessionResult Fail(string? reason) => new(false, null, string.IsNullOrWhiteSpace(reason) ? "未知原因" : reason.Trim());
}
public static async Task<SessionConvertResult> TryConvertSqliteSessionFromJsonAsync(
string phone,
int apiId,
@@ -395,6 +401,14 @@ public static class SessionDataConverter
}
}
private static string Base64UrlEncode(byte[] bytes)
{
var base64 = Convert.ToBase64String(bytes);
// 注意:@mtcute/convert 在 Telethon->tdata 转换时,要求保留 base64 padding(尾部 '=')。
// 若去掉 padding,可能会把 auth key 解析成 248 字节,导致导出的 tdata 无法被官方客户端使用。
return base64.Replace('+', '-').Replace('/', '_');
}
private readonly record struct TelethonSessionData(int DcId, string IpAddress, ushort Port, byte[] AuthKey);
private static bool TryParseTelethonStringSession(string sessionString, out TelethonSessionData data)
@@ -438,6 +452,181 @@ public static class SessionDataConverter
}
}
public static TelethonStringSessionResult TryCreateTelethonStringSessionFromWTelegramSessionFile(
string sessionPath,
int apiId,
string apiHash,
string? phone,
long? userId,
ILogger logger)
{
try
{
var absoluteSessionPath = Path.GetFullPath(sessionPath);
if (!File.Exists(absoluteSessionPath))
return TelethonStringSessionResult.Fail($"session 文件不存在:{absoluteSessionPath}");
string Config(string what) => what switch
{
"api_id" => apiId.ToString(),
"api_hash" => apiHash,
"session_key" => apiHash,
"session_pathname" => absoluteSessionPath,
"phone_number" => NormalizePhone(phone),
"user_id" => userId.HasValue && userId.Value > 0 ? userId.Value.ToString() : null!,
_ => null!
};
using var client = new Client(Config);
var clientType = typeof(Client);
var currentDcSessionField = clientType.GetField("_dcSession", BindingFlags.Instance | BindingFlags.NonPublic);
var sessionField = clientType.GetField("_session", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("无法访问 WTelegram.Client._session");
var sessionObj = sessionField.GetValue(client) ?? throw new InvalidOperationException("WTelegram session 未初始化");
var sessionType = sessionObj.GetType();
var mainDc = (int?)sessionType.GetField("MainDC")?.GetValue(sessionObj) ?? 0;
var dcSessionsField = sessionType.GetField("DCSessions");
var dcSessions = dcSessionsField?.GetValue(sessionObj) as System.Collections.IDictionary;
if (dcSessions == null || dcSessions.Count == 0)
return TelethonStringSessionResult.Fail("session 中未找到任何 DC 会话数据");
var candidateSessions = new List<(object DcSessionObj, bool IsCurrentDc)>();
var currentDcSessionObj = currentDcSessionField?.GetValue(client);
if (currentDcSessionObj != null)
candidateSessions.Add((currentDcSessionObj, true));
if (mainDc > 0 && dcSessions.Contains(mainDc) && dcSessions[mainDc] != null)
candidateSessions.Add((dcSessions[mainDc]!, false));
foreach (System.Collections.DictionaryEntry entry in dcSessions)
{
if (entry.Value != null)
candidateSessions.Add((entry.Value, false));
}
byte[]? authKey = null;
DcOption? dataCenterObj = null;
long pickedDcUserId = 0;
var bestScore = int.MinValue;
foreach (var (dcSessionObj, isCurrentDc) in candidateSessions)
{
if (!TryExtractDcSessionInfo(dcSessionObj, out var candidateAuthKey, out var candidateDataCenter, out var dcUserId))
continue;
if (candidateAuthKey == null || candidateAuthKey.Length != 256)
continue;
if (candidateDataCenter == null)
continue;
var score = 0;
if (isCurrentDc)
score += 100;
if (dcUserId > 0)
score += 50;
if (userId.HasValue && userId.Value > 0 && dcUserId == userId.Value)
score += 40;
if (mainDc > 0 && candidateDataCenter.id == mainDc)
score += 20;
if (score <= bestScore)
continue;
bestScore = score;
authKey = candidateAuthKey;
dataCenterObj = candidateDataCenter;
pickedDcUserId = dcUserId;
}
if (authKey == null || authKey.Length != 256 || dataCenterObj == null)
return TelethonStringSessionResult.Fail("无法从 session 中解析有效的已授权 DCSession(AuthKey/DataCenter)");
if (dataCenterObj.id <= 0 || dataCenterObj.id > byte.MaxValue)
return TelethonStringSessionResult.Fail($"DataCenter ID 无效:{dataCenterObj.id}");
if (string.IsNullOrWhiteSpace(dataCenterObj.ip_address))
return TelethonStringSessionResult.Fail("DataCenter IP 为空");
if (dataCenterObj.port <= 0 || dataCenterObj.port > ushort.MaxValue)
return TelethonStringSessionResult.Fail($"DataCenter 端口无效:{dataCenterObj.port}");
if (!IPAddress.TryParse(dataCenterObj.ip_address.Trim(), out var ip))
return TelethonStringSessionResult.Fail($"DataCenter IP 无法解析:{dataCenterObj.ip_address}");
var ipBytes = ip.GetAddressBytes();
if (ipBytes.Length is not (4 or 16))
return TelethonStringSessionResult.Fail($"DataCenter IP 字节长度无效:{ipBytes.Length}");
var packed = new byte[1 + ipBytes.Length + 2 + 256];
packed[0] = (byte)dataCenterObj.id;
Buffer.BlockCopy(ipBytes, 0, packed, 1, ipBytes.Length);
BinaryPrimitives.WriteUInt16BigEndian(packed.AsSpan(1 + ipBytes.Length, 2), (ushort)dataCenterObj.port);
Buffer.BlockCopy(authKey, 0, packed, 1 + ipBytes.Length + 2, 256);
var sessionString = "1" + Base64UrlEncode(packed);
logger.LogInformation(
"Picked DCSession for Telethon export: mainDc={MainDc}, dcId={DcId}, dcUserId={DcUserId}, targetUserId={TargetUserId}, isIpV6={IsIpV6}",
mainDc,
dataCenterObj.id,
pickedDcUserId,
userId,
ipBytes.Length == 16);
return TelethonStringSessionResult.Success(sessionString);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to create telethon string session from WTelegram session file: {SessionPath}", sessionPath);
return TelethonStringSessionResult.Fail($"{ex.GetType().Name}: {ex.Message}");
}
}
private static bool TryExtractDcSessionInfo(
object dcSessionObj,
out byte[]? authKey,
out DcOption? dataCenter,
out long dcUserId)
{
try
{
var dcSessionType = dcSessionObj.GetType();
authKey = dcSessionType.GetField("AuthKey")?.GetValue(dcSessionObj) as byte[];
dataCenter = dcSessionType.GetField("DataCenter")?.GetValue(dcSessionObj) as DcOption;
dcUserId = 0;
var userIdObj = dcSessionType.GetField("UserId")?.GetValue(dcSessionObj);
switch (userIdObj)
{
case int i:
dcUserId = i;
break;
case long l:
dcUserId = l;
break;
case uint ui:
dcUserId = ui;
break;
case ulong ul:
dcUserId = (long)Math.Min(ul, long.MaxValue);
break;
case short s:
dcUserId = s;
break;
case ushort us:
dcUserId = us;
break;
}
return authKey != null && dataCenter != null;
}
catch
{
authKey = null;
dataCenter = null;
dcUserId = 0;
return false;
}
}
private static async Task<SessionConvertResult> WriteWTelegramSessionFileAsync(
int apiId,
string apiHash,
@@ -6,7 +6,7 @@ using Microsoft.Extensions.Logging;
namespace TelegramPanel.Core.Services.Telegram;
internal static class TdataSessionBridge
public static class TdataSessionBridge
{
private static readonly SemaphoreSlim SetupLock = new(1, 1);
private static readonly string RuntimeDir = ResolveRuntimeDir();
@@ -23,7 +23,7 @@ internal static class TdataSessionBridge
return Path.Combine(Path.GetTempPath(), "telegram-panel-tdata-runtime");
}
private const string NodeScript = """
private const string NodeScript = """
import { convertFromTdata, convertToTelethonSession } from '@mtcute/convert';
const inputPath = process.argv[process.argv.length - 1];
@@ -38,6 +38,33 @@ try {
console.log(JSON.stringify({ ok: false, error: message }));
process.exit(1);
}
""";
private const string NodeScriptTelethonToTdata = """
import { convertFromTelethonSession, convertToTdata } from '@mtcute/convert';
const telethonSession = process.argv[process.argv.length - 3];
const userIdRaw = process.argv[process.argv.length - 2];
const outputDir = process.argv[process.argv.length - 1];
try {
const session = convertFromTelethonSession(telethonSession);
const userId = Number.parseInt(userIdRaw, 10);
if (Number.isFinite(userId) && userId > 0) {
session.self = {
userId,
isBot: false,
isPremium: false,
usernames: [],
};
}
await convertToTdata(session, { path: outputDir });
console.log(JSON.stringify({ ok: true }));
} catch (error) {
const message = error?.stack ? String(error.stack) : String(error);
console.log(JSON.stringify({ ok: false, error: message }));
process.exit(1);
}
""";
public readonly record struct ConvertResult(bool Ok, string? SessionString, long? UserId, string? Error)
@@ -46,6 +73,12 @@ try {
public static ConvertResult Fail(string error) => new(false, null, null, string.IsNullOrWhiteSpace(error) ? "未知错误" : error.Trim());
}
public readonly record struct ConvertToTdataResult(bool Ok, string? Error)
{
public static ConvertToTdataResult Success() => new(true, null);
public static ConvertToTdataResult Fail(string error) => new(false, string.IsNullOrWhiteSpace(error) ? "未知错误" : error.Trim());
}
public static async Task<ConvertResult> TryConvertToTelethonStringSessionAsync(
string tdataDirectory,
ILogger logger,
@@ -116,6 +149,89 @@ try {
}
}
public static async Task<ConvertToTdataResult> TryConvertTelethonStringSessionToTdataAsync(
string telethonSessionString,
long? userId,
string outputTdataDirectory,
ILogger logger,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(telethonSessionString))
return ConvertToTdataResult.Fail("telethon session_string 为空");
if (string.IsNullOrWhiteSpace(outputTdataDirectory))
return ConvertToTdataResult.Fail("tdata 输出目录为空");
var runtimeReady = await EnsureRuntimeReadyAsync(logger, cancellationToken);
if (!runtimeReady.Ok)
return ConvertToTdataResult.Fail(runtimeReady.Error ?? "tdata 运行环境初始化失败");
try
{
var normalizedSession = NormalizeTelethonSessionString(telethonSessionString);
var absoluteOutputDir = Path.GetFullPath(outputTdataDirectory);
if (Directory.Exists(absoluteOutputDir))
Directory.Delete(absoluteOutputDir, recursive: true);
Directory.CreateDirectory(absoluteOutputDir);
var run = await RunProcessAsync(
fileName: "node",
arguments: new[] { "--input-type=module", "-e", NodeScriptTelethonToTdata, "--", normalizedSession, (userId ?? 0).ToString(), absoluteOutputDir },
workingDirectory: RuntimeDir,
timeoutMs: ConvertTimeoutMs,
cancellationToken: cancellationToken);
var output = PickJsonLine(run.StdOut);
if (string.IsNullOrWhiteSpace(output))
{
var msg = string.IsNullOrWhiteSpace(run.StdErr) ? "node 输出为空" : run.StdErr.Trim();
return ConvertToTdataResult.Fail(msg);
}
using var doc = JsonDocument.Parse(output);
var root = doc.RootElement;
var ok = root.TryGetProperty("ok", out var okProp) && okProp.ValueKind == JsonValueKind.True;
if (!ok)
{
var err = root.TryGetProperty("error", out var errProp) && errProp.ValueKind == JsonValueKind.String
? (errProp.GetString() ?? string.Empty)
: (string.IsNullOrWhiteSpace(run.StdErr) ? "未知错误" : run.StdErr.Trim());
return ConvertToTdataResult.Fail(err);
}
if (!Directory.EnumerateFileSystemEntries(absoluteOutputDir).Any())
return ConvertToTdataResult.Fail("tdata 输出目录为空");
return ConvertToTdataResult.Success();
}
catch (TimeoutException ex)
{
logger.LogWarning(ex, "Telethon->tdata conversion timed out");
return ConvertToTdataResult.Fail("Telethon->tdata 转换超时");
}
catch (Exception ex)
{
logger.LogWarning(ex, "Telethon->tdata conversion failed");
return ConvertToTdataResult.Fail(ex.Message);
}
}
private static string NormalizeTelethonSessionString(string sessionString)
{
var trimmed = sessionString.Trim();
if (trimmed.Length < 2 || !char.IsDigit(trimmed[0]))
return trimmed;
var body = trimmed[1..];
var mod = body.Length % 4;
if (mod == 0)
return trimmed;
if (mod == 1)
return trimmed; // 非法长度,保持原样让下游报错更明确
var padding = mod == 2 ? "==" : "=";
return $"{trimmed}{padding}";
}
private static async Task<(bool Ok, string? Error)> EnsureRuntimeReadyAsync(ILogger logger, CancellationToken cancellationToken)
{
await SetupLock.WaitAsync(cancellationToken);
+2
View File
@@ -177,10 +177,12 @@ public class AppDbContext : DbContext
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Username).HasMaxLength(100);
entity.Property(e => e.About).HasMaxLength(1000);
entity.Property(e => e.ChannelStatusError).HasMaxLength(500);
entity.HasIndex(e => e.TelegramId).IsUnique();
entity.HasIndex(e => e.Username);
entity.HasIndex(e => e.CategoryId);
entity.HasIndex(e => e.ChannelStatusOk);
entity.HasOne(e => e.Category)
.WithMany(c => c.Channels)
@@ -16,6 +16,21 @@ public class BotChannel
public DateTime? CreatedAt { get; set; }
public DateTime SyncedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// 频道状态检测结果:true=正常,false=异常,null=未检测
/// </summary>
public bool? ChannelStatusOk { get; set; }
/// <summary>
/// 最近一次频道状态检测时间(UTC)
/// </summary>
public DateTime? ChannelStatusCheckedAtUtc { get; set; }
/// <summary>
/// 最近一次检测失败原因(仅异常时有值)
/// </summary>
public string? ChannelStatusError { get; set; }
public int? CategoryId { get; set; }
public BotChannelCategory? Category { get; set; }
@@ -0,0 +1,632 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using TelegramPanel.Data;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260302171929_AddBotChannelStatusFields")]
partial class AddBotChannelStatusFields
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.22");
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ApiHash")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int>("ApiId")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<DateTime>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<string>("Nickname")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Phone")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("SessionPath")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<DateTime?>("TelegramStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("TelegramStatusDetails")
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<bool?>("TelegramStatusOk")
.HasColumnType("INTEGER");
b.Property<string>("TelegramStatusSummary")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("TwoFactorPassword")
.HasColumnType("TEXT");
b.Property<long>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("Phone")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Color")
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool>("ExcludeFromOperations")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("AccountCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccountId")
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdmin")
.HasColumnType("INTEGER");
b.Property<bool>("IsCreator")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("AccountId", "ChannelId")
.IsUnique();
b.ToTable("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BatchTask", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Completed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CompletedAt")
.HasColumnType("TEXT");
b.Property<string>("Config")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("Failed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("TEXT");
b.Property<string>("TaskType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int>("Total")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("CreatedAt");
b.HasIndex("Status");
b.ToTable("BatchTasks");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastSyncAt")
.HasColumnType("TEXT");
b.Property<long?>("LastUpdateId")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Bots");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("BotChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("BotChannelCategories");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BotChannelId")
.HasColumnType("INTEGER");
b.Property<int>("BotId")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BotChannelId");
b.HasIndex("BotId");
b.HasIndex("BotId", "BotChannelId")
.IsUnique();
b.ToTable("BotChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int?>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int?>("GroupId")
.HasColumnType("INTEGER");
b.Property<bool>("IsBroadcast")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("GroupId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ChannelGroups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("About")
.HasMaxLength(1000)
.HasColumnType("TEXT");
b.Property<long?>("AccessHash")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CreatorAccountId")
.HasColumnType("INTEGER");
b.Property<int>("MemberCount")
.HasColumnType("INTEGER");
b.Property<DateTime>("SyncedAt")
.HasColumnType("TEXT");
b.Property<long>("TelegramId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Username")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CreatorAccountId");
b.HasIndex("TelegramId")
.IsUnique();
b.HasIndex("Username");
b.ToTable("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.HasOne("TelegramPanel.Data.Entities.AccountCategory", "Category")
.WithMany("Accounts")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "Account")
.WithMany("AccountChannels")
.HasForeignKey("AccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Channel", "Channel")
.WithMany("AccountChannels")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Account");
b.Navigation("Channel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannelCategory", "Category")
.WithMany("Channels")
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Category");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelMember", b =>
{
b.HasOne("TelegramPanel.Data.Entities.BotChannel", "BotChannel")
.WithMany("Members")
.HasForeignKey("BotChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("TelegramPanel.Data.Entities.Bot", "Bot")
.WithMany("ChannelMembers")
.HasForeignKey("BotId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Bot");
b.Navigation("BotChannel");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Channels")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("TelegramPanel.Data.Entities.ChannelGroup", "Group")
.WithMany("Channels")
.HasForeignKey("GroupId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("CreatorAccount");
b.Navigation("Group");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Group", b =>
{
b.HasOne("TelegramPanel.Data.Entities.Account", "CreatorAccount")
.WithMany("Groups")
.HasForeignKey("CreatorAccountId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatorAccount");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Account", b =>
{
b.Navigation("AccountChannels");
b.Navigation("Channels");
b.Navigation("Groups");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.AccountCategory", b =>
{
b.Navigation("Accounts");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Bot", b =>
{
b.Navigation("ChannelMembers");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannel", b =>
{
b.Navigation("Members");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.BotChannelCategory", b =>
{
b.Navigation("Channels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.Channel", b =>
{
b.Navigation("AccountChannels");
});
modelBuilder.Entity("TelegramPanel.Data.Entities.ChannelGroup", b =>
{
b.Navigation("Channels");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TelegramPanel.Data.Migrations
{
/// <inheritdoc />
public partial class AddBotChannelStatusFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ChannelStatusCheckedAtUtc",
table: "BotChannels",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChannelStatusError",
table: "BotChannels",
type: "TEXT",
maxLength: 500,
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "ChannelStatusOk",
table: "BotChannels",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_BotChannels_ChannelStatusOk",
table: "BotChannels",
column: "ChannelStatusOk");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_BotChannels_ChannelStatusOk",
table: "BotChannels");
migrationBuilder.DropColumn(
name: "ChannelStatusCheckedAtUtc",
table: "BotChannels");
migrationBuilder.DropColumn(
name: "ChannelStatusError",
table: "BotChannels");
migrationBuilder.DropColumn(
name: "ChannelStatusOk",
table: "BotChannels");
}
}
}
@@ -266,6 +266,16 @@ namespace TelegramPanel.Data.Migrations
b.Property<int?>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("ChannelStatusCheckedAtUtc")
.HasColumnType("TEXT");
b.Property<string>("ChannelStatusError")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<bool?>("ChannelStatusOk")
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
@@ -294,6 +304,8 @@ namespace TelegramPanel.Data.Migrations
b.HasIndex("CategoryId");
b.HasIndex("ChannelStatusOk");
b.HasIndex("TelegramId")
.IsUnique();
@@ -9,7 +9,7 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
{
}
private IQueryable<BotChannel> BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search)
private IQueryable<BotChannel> BuildQuery(int botId, int? categoryId, bool broadcastOnly, string? search, int statusFilter)
{
// 兼容旧模块/旧 UI 约定:categoryId=0 表示“不筛选分类”
if (categoryId == 0)
@@ -32,6 +32,16 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
if (broadcastOnly)
query = query.Where(x => x.IsBroadcast);
// 频道状态筛选:
// 0=全部,1=正常,2=异常,3=未检测
query = statusFilter switch
{
1 => query.Where(x => x.ChannelStatusOk == true),
2 => query.Where(x => x.ChannelStatusOk == false),
3 => query.Where(x => x.ChannelStatusOk == null),
_ => query
};
search = (search ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(search))
{
@@ -87,6 +97,7 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
int? categoryId,
bool broadcastOnly,
string? search,
int statusFilter,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default)
@@ -95,7 +106,7 @@ public class BotChannelRepository : Repository<BotChannel>, IBotChannelRepositor
if (pageSize <= 0) pageSize = 20;
if (pageSize > 500) pageSize = 500;
var query = BuildQuery(botId, categoryId, broadcastOnly, search);
var query = BuildQuery(botId, categoryId, broadcastOnly, search, statusFilter);
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip(pageIndex * pageSize)
@@ -13,6 +13,7 @@ public interface IBotChannelRepository : IRepository<BotChannel>
int? categoryId,
bool broadcastOnly,
string? search,
int statusFilter,
int pageIndex,
int pageSize,
CancellationToken cancellationToken = default);
@@ -0,0 +1,49 @@
<MudDialog>
<DialogContent>
<MudText Typo="Typo.body1">
将导出 @AccountCount 个账号(@ScopeLabel)。
</MudText>
<MudText Typo="Typo.body2" Class="mt-2">
请选择导出格式:
</MudText>
<MudPaper Class="pa-3 mt-3" Outlined="true">
<MudText Typo="Typo.subtitle2">Telethon(默认)</MudText>
<MudText Typo="Typo.caption">每个账号导出 `.json + .session (+2fa.txt)`,适合现有批量导入流程。</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-2" OnClick="ExportTelethon">
导出 Telethon
</MudButton>
</MudPaper>
<MudPaper Class="pa-3 mt-3" Outlined="true">
<MudText Typo="Typo.subtitle2">Tdata</MudText>
<MudText Typo="Typo.caption">每个账号额外导出 `tdata/`,并同时保留 `.json + .session (+2fa.txt)`。</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Secondary" Class="mt-2" OnClick="ExportTdata">
导出 Tdata
</MudButton>
</MudPaper>
</DialogContent>
<DialogActions>
<MudButton Variant="Variant.Text" Color="Color.Default" OnClick="Cancel">取消</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!;
[Parameter] public int AccountCount { get; set; }
[Parameter] public string ScopeLabel { get; set; } = "账号";
private void ExportTelethon() => Submit("telethon");
private void ExportTdata() => Submit("tdata");
private void Submit(string format)
{
MudDialog.Close(DialogResult.Ok(format));
}
private void Cancel()
{
MudDialog.Cancel();
}
}
@@ -87,8 +87,8 @@
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeUsername">批量改用户名(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="OpenBatchChangeBio">批量改Bio(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selectedAccounts.Count == 0)" OnClick="DeleteSelectedAccounts">删除已选</MudMenuItem>
<MudMenuItem OnClick="ExportSelectedAccounts">导出已选</MudMenuItem>
<MudMenuItem OnClick="ExportCurrentPageAccounts">导出当前页</MudMenuItem>
<MudMenuItem OnClick="ExportSelectedAccountsAsync">导出已选</MudMenuItem>
<MudMenuItem OnClick="ExportCurrentPageAccountsAsync">导出当前页</MudMenuItem>
</MudMenu>
@if (selectedAccounts.Count > 0)
@@ -927,7 +927,7 @@
}
}
private void ExportSelectedAccounts()
private async Task ExportSelectedAccountsAsync()
{
var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
@@ -936,11 +936,10 @@
return;
}
var qs = string.Join(",", ids);
Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}", forceLoad: true);
await ExportAccountsByIdsAsync(ids, "已选账号");
}
private void ExportCurrentPageAccounts()
private async Task ExportCurrentPageAccountsAsync()
{
var ids = accounts.Select(a => a.Id).Distinct().ToList();
if (ids.Count == 0)
@@ -949,8 +948,36 @@
return;
}
await ExportAccountsByIdsAsync(ids, "当前页账号");
}
private async Task ExportAccountsByIdsAsync(IReadOnlyList<int> ids, string scopeLabel)
{
var format = await SelectExportFormatAsync(ids.Count, scopeLabel);
if (string.IsNullOrWhiteSpace(format))
return;
var qs = string.Join(",", ids);
Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}", forceLoad: true);
var ts = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
Navigation.NavigateTo($"/downloads/accounts.zip?ids={qs}&format={format}&ts={ts}", forceLoad: true);
}
private async Task<string?> SelectExportFormatAsync(int accountCount, string scopeLabel)
{
var parameters = new DialogParameters
{
["AccountCount"] = accountCount,
["ScopeLabel"] = scopeLabel
};
var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
var dialog = DialogService.Show<TelegramPanel.Web.Components.Dialogs.ExportAccountFormatDialog>("选择导出格式", parameters, options)!;
var result = await dialog.Result;
if (result is not { Canceled: false } || result.Data is not string formatRaw)
return null;
return string.Equals(formatRaw, "tdata", StringComparison.OrdinalIgnoreCase)
? "tdata"
: "telethon";
}
private async Task OpenSystemInbox(Account account)
@@ -35,6 +35,15 @@
}
</MudSelect>
<MudSelect @bind-Value="filterStatus" @bind-Value:after="OnFiltersChanged" Label="频道状态" Variant="Variant.Outlined" Dense="true"
Class="tp-input-full-xs"
Style="min-width: 160px; flex: 0 1 200px;" Disabled="@(selectedBotId <= 0)">
<MudSelectItem Value="0">全部状态</MudSelectItem>
<MudSelectItem Value="1">正常</MudSelectItem>
<MudSelectItem Value="2">异常</MudSelectItem>
<MudSelectItem Value="3">未检测</MudSelectItem>
</MudSelect>
<MudIconButton Icon="@Icons.Material.Filled.Add" Color="Color.Primary" Size="Size.Small"
Title="新建分类" OnClick="OpenCreateCategoryDialog"
Disabled="@(selectedBotId <= 0)" />
@@ -72,12 +81,14 @@
<MudMenu Label="批量操作" Variant="Variant.Outlined" Color="Color.Default" StartIcon="@Icons.Material.Filled.MoreHoriz"
Size="Size.Small" Disabled="@(loading || selectedBotId <= 0)" Dense="true" EndIcon="@Icons.Material.Filled.KeyboardArrowDown">
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="CheckSelectedChannelsStatus">检测状态(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="ExportInvites">导出邀请(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchInviteMembers">批量邀请成员(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetAdminsByAccount">批量设置管理员(账号执行)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenSetAdmins">批量设置管理员(机器人/ID)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBatchSetCategory">批量设置分类(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="OpenBanMember">踢出用户(已选)</MudMenuItem>
<MudMenuItem Disabled="@(selected.Count == 0)" OnClick="DeleteSelectedChannelsAsync">删除频道(已选)</MudMenuItem>
</MudMenu>
@if (selected.Count > 0)
@@ -99,6 +110,7 @@
<MudTh>频道名称</MudTh>
<MudTh>用户名</MudTh>
<MudTh>分类</MudTh>
<MudTh>频道状态</MudTh>
<MudTh>最后同步</MudTh>
<MudTh>操作</MudTh>
</HeaderContent>
@@ -126,15 +138,26 @@
<MudText Typo="Typo.body2" Color="Color.Default">未分类</MudText>
}
</MudTd>
<MudTd DataLabel="频道状态">
<MudTooltip Text="@GetChannelStatusTooltip(context)">
<MudChip T="string" Size="Size.Small" Color="@GetChannelStatusColor(context)">
@GetChannelStatusText(context)
</MudChip>
</MudTooltip>
</MudTd>
<MudTd DataLabel="最后同步"><PanelTime Value="context.SyncedAt" /></MudTd>
<MudTd DataLabel="操作">
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center" Style="flex-wrap: wrap;">
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Size="Size.Small" Color="Color.Success"
OnClick="@(async () => await CheckSingleChannelStatus(context))" title="检测状态" />
<MudIconButton Icon="@Icons.Material.Filled.Link" Size="Size.Small" Color="Color.Info"
OnClick="@(async () => await CopyLinkAsync(context))" Title="复制链接" />
<MudIconButton Icon="@Icons.Material.Filled.Info" Size="Size.Small" Color="Color.Default"
OnClick="@(async () => await ShowDetailsAsync(context))" Title="查看详情" />
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" Color="Color.Primary"
OnClick="@(async () => await EditChannelAsync(context))" Title="编辑频道" />
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" Color="Color.Error"
OnClick="@(async () => await DeleteSingleChannelAsync(context))" title="删除频道" />
</MudStack>
</MudTd>
</RowTemplate>
@@ -172,6 +195,7 @@
private HashSet<BotChannel> selected = new();
private int filterCategoryId = 0;
private int filterStatus = 0;
private string search = "";
private bool _actionsExpanded = true;
private CancellationTokenSource? _searchDebounceCts;
@@ -227,6 +251,7 @@
selectedBotId = botId;
filterCategoryId = 0;
filterStatus = 0;
search = "";
selected.Clear();
@@ -285,6 +310,7 @@
botId: selectedBotId,
categoryId: filterCategoryId == -1 ? -1 : (filterCategoryId > 0 ? filterCategoryId : null),
search: search,
statusFilter: filterStatus,
pageIndex: state.Page,
pageSize: state.PageSize,
cancellationToken: cancellationToken);
@@ -367,6 +393,182 @@
}
}
private async Task CheckSingleChannelStatus(BotChannel channel)
{
if (selectedBotId <= 0)
return;
loading = true;
try
{
var result = await BotTelegram.CheckChannelsStatusAsync(
selectedBotId,
new List<long> { channel.TelegramId },
CancellationToken.None);
if (result.FailedCount == 0)
{
Snackbar.Add($"频道“{channel.Title}”检测正常", Severity.Success);
}
else
{
var msg = result.Failures.TryGetValue(channel.TelegramId, out var err) ? err : "检测失败";
Snackbar.Add($"频道“{channel.Title}”检测异常:{msg}", Severity.Warning);
}
await ReloadTable();
}
catch (Exception ex)
{
Snackbar.Add($"检测失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task CheckSelectedChannelsStatus()
{
if (selectedBotId <= 0)
return;
if (selected.Count == 0)
{
Snackbar.Add("请先选择频道", Severity.Info);
return;
}
bool? ok = await DialogService.ShowMessageBox(
"确认检测",
$"将检测 {selected.Count} 个频道的状态(发送一条探测消息并立即删除)。是否继续?",
yesText: "继续", cancelText: "取消");
if (ok != true)
return;
loading = true;
try
{
var ids = selected.Select(x => x.TelegramId).Distinct().ToList();
var result = await BotTelegram.CheckChannelsStatusAsync(selectedBotId, ids, CancellationToken.None);
if (result.FailedCount == 0)
{
Snackbar.Add($"检测完成:{result.SuccessCount} 个频道均正常", Severity.Success);
}
else
{
var first = result.Failures.FirstOrDefault();
var msg = $"检测完成:正常 {result.SuccessCount} 个,异常 {result.FailedCount} 个";
if (first.Key != 0)
msg += $"(首个失败:{first.Value})";
Snackbar.Add(msg, Severity.Warning);
}
await ReloadTable();
}
catch (Exception ex)
{
Snackbar.Add($"检测失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task DeleteSingleChannelAsync(BotChannel channel)
{
if (selectedBotId <= 0)
return;
var channelName = string.IsNullOrWhiteSpace(channel.Title) ? channel.TelegramId.ToString() : channel.Title;
bool? ok = await DialogService.ShowMessageBox(
"确认删除",
$"确定删除频道“{channelName}”吗?此操作只会移除面板中的 Bot 频道记录。",
yesText: "删除", cancelText: "取消");
if (ok != true)
return;
loading = true;
try
{
await BotManagement.DeleteChannelByTelegramIdAsync(selectedBotId, channel.TelegramId);
Snackbar.Add($"已删除频道:{channelName}", Severity.Success);
await ReloadTable();
}
catch (Exception ex)
{
Snackbar.Add($"删除失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task DeleteSelectedChannelsAsync()
{
if (selectedBotId <= 0)
return;
if (selected.Count == 0)
{
Snackbar.Add("请先选择频道", Severity.Info);
return;
}
bool? ok = await DialogService.ShowMessageBox(
"确认批量删除",
$"确定删除已选 {selected.Count} 个频道吗?此操作只会移除面板中的 Bot 频道记录。",
yesText: "删除", cancelText: "取消");
if (ok != true)
return;
loading = true;
try
{
var ids = selected.Select(x => x.TelegramId).Distinct().ToList();
var success = 0;
var failed = 0;
foreach (var chatId in ids)
{
try
{
await BotManagement.DeleteChannelByTelegramIdAsync(selectedBotId, chatId);
success++;
}
catch
{
failed++;
}
}
if (failed == 0)
{
Snackbar.Add($"批量删除完成:成功 {success} 个频道", Severity.Success);
}
else
{
Snackbar.Add($"批量删除完成:成功 {success} 个,失败 {failed} 个", Severity.Warning);
}
await ReloadTable();
}
catch (Exception ex)
{
Snackbar.Add($"批量删除失败:{ex.Message}", Severity.Error);
}
finally
{
loading = false;
}
}
private async Task OpenCreateCategoryDialog()
{
if (selectedBotId <= 0)
@@ -741,6 +943,39 @@
await LoadBotData();
}
private static string GetChannelStatusText(BotChannel channel)
{
return channel.ChannelStatusOk switch
{
true => "正常",
false => "异常",
null => "未检测"
};
}
private static Color GetChannelStatusColor(BotChannel channel)
{
return channel.ChannelStatusOk switch
{
true => Color.Success,
false => Color.Error,
null => Color.Default
};
}
private static string GetChannelStatusTooltip(BotChannel channel)
{
if (channel.ChannelStatusOk == null)
return "尚未检测";
var checkedAt = channel.ChannelStatusCheckedAtUtc?.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
if (channel.ChannelStatusOk == true)
return $"最近检测:{checkedAt}";
var err = string.IsNullOrWhiteSpace(channel.ChannelStatusError) ? "未知错误" : channel.ChannelStatusError;
return $"最近检测:{checkedAt};失败原因:{err}";
}
private Task SelectAllFiltered()
{
selected = channels.ToHashSet();
+13 -2
View File
@@ -856,8 +856,19 @@ var accountsZipDownload = app.MapGet("/downloads/accounts.zip", async (
var all = (await accountManagement.GetAllAccountsAsync()).ToList();
var accounts = ids == null ? all : all.Where(a => ids.Contains(a.Id)).ToList();
var zipBytes = await exporter.BuildAccountsZipAsync(accounts, cancellationToken);
var fileName = $"telegram-panel-accounts-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip";
var formatRaw = (http.Request.Query["format"].ToString() ?? string.Empty).Trim();
var format = string.Equals(formatRaw, "tdata", StringComparison.OrdinalIgnoreCase)
? AccountExportFormat.Tdata
: AccountExportFormat.Telethon;
var zipBytes = await exporter.BuildAccountsZipAsync(accounts, cancellationToken, format);
var formatName = format == AccountExportFormat.Tdata ? "tdata" : "telethon";
var fileName = $"telegram-panel-accounts-{formatName}-{DateTime.UtcNow:yyyyMMdd-HHmmss}.zip";
// 下载文件禁止缓存,避免浏览器复用旧的 accounts.zip 导致“重复拿到旧导出包”。
http.Response.Headers.CacheControl = "no-store, no-cache, must-revalidate, max-age=0";
http.Response.Headers.Pragma = "no-cache";
http.Response.Headers.Expires = "0";
return Results.File(zipBytes, "application/zip", fileName);
}).DisableAntiforgery();
if (adminAuthEnabled)
@@ -3,10 +3,17 @@ using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Services.Telegram;
using TelegramPanel.Data.Entities;
namespace TelegramPanel.Web.Services;
public enum AccountExportFormat
{
Telethon = 0,
Tdata = 1
}
public class AccountExportService
{
private readonly IWebHostEnvironment _environment;
@@ -26,20 +33,15 @@ public class AccountExportService
_clientPool = clientPool;
}
public async Task<byte[]> BuildAccountsZipAsync(IReadOnlyList<Account> accounts, CancellationToken cancellationToken)
public async Task<byte[]> BuildAccountsZipAsync(
IReadOnlyList<Account> accounts,
CancellationToken cancellationToken,
AccountExportFormat format = AccountExportFormat.Telethon)
{
using var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
var readme = zip.CreateEntry("README.txt", CompressionLevel.Fastest);
await using (var readmeStream = readme.Open())
await using (var writer = new StreamWriter(readmeStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))
{
await writer.WriteLineAsync("Telegram Panel 账号导出包");
await writer.WriteLineAsync("结构:每个账号一个子文件夹,包含 .json + .session;如保存了二级密码,则额外包含 2fa.txt。");
await writer.WriteLineAsync("导入:面板 -> 账号 -> 导入账号 -> 压缩包导入(Zip)。");
await writer.WriteLineAsync($"导出时间(UTC):{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}");
}
await WriteReadmeAsync(zip, format);
foreach (var account in accounts)
{
@@ -48,61 +50,47 @@ public class AccountExportService
var phone = (account.Phone ?? string.Empty).Trim();
var safeFolder = BuildSafeFolderName(phone, account.Id);
// 文件夹占位(部分 zip 工具更友好)
_ = zip.CreateEntry($"{safeFolder}/");
try
{
var sessionPath = ResolveSessionPath(account);
var hasSession = false;
if (!File.Exists(sessionPath))
{
_logger.LogWarning("Session file missing for account {AccountId}: {Path}", account.Id, sessionPath);
var warn = zip.CreateEntry($"{safeFolder}/WARN.txt", CompressionLevel.Fastest);
await using var warnStream = warn.Open();
await using var warnWriter = new StreamWriter(warnStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await warnWriter.WriteLineAsync($"未找到 session 文件:{sessionPath}");
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN.txt", $"未找到 session 文件:{sessionPath}");
}
else
{
// 若 session 被本进程的 Telegram 客户端锁定,先移除客户端释放文件锁,然后重试读取
await CopySessionWithRetryAsync(zip, safeFolder, sessionPath, account.Id, cancellationToken);
hasSession = true;
}
var jsonEntry = zip.CreateEntry($"{safeFolder}/{safeFolder}.json", CompressionLevel.Fastest);
await using (var jsonStream = jsonEntry.Open())
await using (var writer = new StreamWriter(jsonStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)))
{
// 兼容当前 Zip 导入逻辑:json 里需要 api_id/api_hash/phone
var json = System.Text.Json.JsonSerializer.Serialize(new
{
phone = phone,
user_id = account.UserId > 0 ? (long?)account.UserId : null,
username = account.Username,
first_name = account.Nickname,
api_id = account.ApiId,
api_hash = account.ApiHash,
exported_at_utc = DateTime.UtcNow
}, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
await writer.WriteAsync(json);
}
await WriteAccountJsonAsync(zip, safeFolder, account, phone);
var twoFactorPassword = (account.TwoFactorPassword ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(twoFactorPassword))
await WriteTextEntryAsync(zip, $"{safeFolder}/2fa.txt", twoFactorPassword);
if (format == AccountExportFormat.Tdata)
{
var twoFaEntry = zip.CreateEntry($"{safeFolder}/2fa.txt", CompressionLevel.Fastest);
await using var twoFaStream = twoFaEntry.Open();
await using var twoFaWriter = new StreamWriter(twoFaStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await twoFaWriter.WriteAsync(twoFactorPassword);
if (!hasSession)
{
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN_TDATA.txt", "无法生成 tdata:session 文件不存在");
}
else
{
var tdataResult = await TryAddTdataPackageAsync(zip, safeFolder, account, sessionPath, cancellationToken);
if (!tdataResult.Ok)
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN_TDATA.txt", $"tdata 生成失败:{tdataResult.Error}");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to export account {AccountId}", account.Id);
var err = zip.CreateEntry($"{safeFolder}/ERROR.txt", CompressionLevel.Fastest);
await using var errStream = err.Open();
await using var errWriter = new StreamWriter(errStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await errWriter.WriteLineAsync($"导出该账号时发生异常:{ex.Message}");
await WriteTextEntryAsync(zip, $"{safeFolder}/ERROR.txt", $"导出该账号时发生异常:{ex.Message}");
}
}
}
@@ -110,6 +98,199 @@ public class AccountExportService
return ms.ToArray();
}
private async Task WriteReadmeAsync(ZipArchive zip, AccountExportFormat format)
{
var readme = zip.CreateEntry("README.txt", CompressionLevel.Fastest);
await using var readmeStream = readme.Open();
await using var writer = new StreamWriter(readmeStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await writer.WriteLineAsync("Telegram Panel 账号导出包");
await writer.WriteLineAsync($"导出时间(UTC):{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}");
await writer.WriteLineAsync($"导出格式:{(format == AccountExportFormat.Tdata ? "tdata(包含 tdata + .json + .session)" : "telethon(.json + .session)")}");
await writer.WriteLineAsync();
await writer.WriteLineAsync("通用结构:每个账号一个子文件夹,至少包含 .json + .session;如保存了二级密码,则额外包含 2fa.txt。");
if (format == AccountExportFormat.Tdata)
await writer.WriteLineAsync("tdata 结构:每个账号目录下额外包含 tdata/ 子目录(key_datas / D877F783D5D3EF8C*)。");
await writer.WriteLineAsync("导入:面板 -> 账号 -> 导入账号 -> 压缩包导入(Zip)。");
}
private async Task WriteAccountJsonAsync(ZipArchive zip, string safeFolder, Account account, string phone)
{
var jsonEntry = zip.CreateEntry($"{safeFolder}/{safeFolder}.json", CompressionLevel.Fastest);
await using var jsonStream = jsonEntry.Open();
await using var writer = new StreamWriter(jsonStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
var json = System.Text.Json.JsonSerializer.Serialize(new
{
phone = phone,
user_id = account.UserId > 0 ? (long?)account.UserId : null,
username = account.Username,
first_name = account.Nickname,
api_id = account.ApiId,
api_hash = account.ApiHash,
exported_at_utc = DateTime.UtcNow
}, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
await writer.WriteAsync(json);
}
private readonly record struct TdataExportResult(bool Ok, string? Error)
{
public static TdataExportResult Success() => new(true, null);
public static TdataExportResult Fail(string? error) => new(false, string.IsNullOrWhiteSpace(error) ? "未知原因" : error.Trim());
}
private async Task<TdataExportResult> TryAddTdataPackageAsync(
ZipArchive zip,
string safeFolder,
Account account,
string sourceSessionPath,
CancellationToken cancellationToken)
{
string? tempSessionPath = null;
string? tempTdataDir = null;
try
{
if (account.ApiId <= 0 || string.IsNullOrWhiteSpace(account.ApiHash))
return TdataExportResult.Fail("账号缺少 ApiId 或 ApiHash");
tempSessionPath = Path.Combine(Path.GetTempPath(), $"telegram-panel-export-{Guid.NewGuid():N}.session");
tempTdataDir = Path.Combine(Path.GetTempPath(), $"telegram-panel-export-tdata-{Guid.NewGuid():N}");
// 若 session 被当前进程锁定,先释放客户端后重试复制
await CopySessionFileToPathWithRetryAsync(
sourceSessionPath: sourceSessionPath,
targetSessionPath: tempSessionPath,
accountId: account.Id,
cancellationToken: cancellationToken);
var telethonResult = SessionDataConverter.TryCreateTelethonStringSessionFromWTelegramSessionFile(
sessionPath: tempSessionPath,
apiId: account.ApiId,
apiHash: account.ApiHash.Trim(),
phone: account.Phone,
userId: account.UserId > 0 ? account.UserId : null,
logger: _logger);
if (!telethonResult.Ok || string.IsNullOrWhiteSpace(telethonResult.SessionString))
return TdataExportResult.Fail($"生成 Telethon session_string 失败:{telethonResult.Reason}");
var tdataResult = await TdataSessionBridge.TryConvertTelethonStringSessionToTdataAsync(
telethonSessionString: telethonResult.SessionString,
userId: account.UserId > 0 ? account.UserId : null,
outputTdataDirectory: tempTdataDir,
logger: _logger,
cancellationToken: cancellationToken);
if (!tdataResult.Ok)
return TdataExportResult.Fail(tdataResult.Error);
await AddDirectoryToZipAsync(
zip: zip,
sourceDirectory: tempTdataDir,
zipDirectoryPrefix: $"{safeFolder}/tdata",
cancellationToken: cancellationToken);
return TdataExportResult.Success();
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to build tdata package for account {AccountId}", account.Id);
return TdataExportResult.Fail(ex.Message);
}
finally
{
try
{
if (!string.IsNullOrWhiteSpace(tempSessionPath) && File.Exists(tempSessionPath))
File.Delete(tempSessionPath);
}
catch
{
// ignore
}
try
{
if (!string.IsNullOrWhiteSpace(tempTdataDir) && Directory.Exists(tempTdataDir))
Directory.Delete(tempTdataDir, recursive: true);
}
catch
{
// ignore
}
}
}
private async Task CopySessionFileToPathWithRetryAsync(
string sourceSessionPath,
string targetSessionPath,
int accountId,
CancellationToken cancellationToken)
{
const int maxAttempts = 3;
Directory.CreateDirectory(Path.GetDirectoryName(targetSessionPath) ?? Path.GetTempPath());
for (var attempt = 1; attempt <= maxAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await using var source = new FileStream(sourceSessionPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
await using var target = new FileStream(targetSessionPath, FileMode.Create, FileAccess.Write, FileShare.None);
await source.CopyToAsync(target, cancellationToken);
return;
}
catch (IOException ex) when (attempt < maxAttempts)
{
_logger.LogWarning(ex, "Session file is locked while preparing tdata export (attempt {Attempt}/{Max}) for account {AccountId}: {Path}",
attempt, maxAttempts, accountId, sourceSessionPath);
try
{
await _clientPool.RemoveClientAsync(accountId);
}
catch (Exception removeEx)
{
_logger.LogDebug(removeEx, "Failed to remove client for account {AccountId} while preparing tdata export", accountId);
}
await Task.Delay(200, cancellationToken);
}
}
throw new IOException($"session 文件被占用无法读取:{sourceSessionPath}");
}
private static async Task AddDirectoryToZipAsync(
ZipArchive zip,
string sourceDirectory,
string zipDirectoryPrefix,
CancellationToken cancellationToken)
{
var normalizedPrefix = zipDirectoryPrefix.TrimEnd('/').Replace('\\', '/');
_ = zip.CreateEntry($"{normalizedPrefix}/");
foreach (var filePath in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories))
{
cancellationToken.ThrowIfCancellationRequested();
var relativePath = Path.GetRelativePath(sourceDirectory, filePath).Replace('\\', '/');
var entryPath = $"{normalizedPrefix}/{relativePath}";
var entry = zip.CreateEntry(entryPath, CompressionLevel.Fastest);
await using var entryStream = entry.Open();
await using var fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
await fileStream.CopyToAsync(entryStream, cancellationToken);
}
}
private static async Task WriteTextEntryAsync(ZipArchive zip, string path, string content)
{
var entry = zip.CreateEntry(path, CompressionLevel.Fastest);
await using var entryStream = entry.Open();
await using var writer = new StreamWriter(entryStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await writer.WriteAsync(content ?? string.Empty);
}
private async Task CopySessionWithRetryAsync(
ZipArchive zip,
string safeFolder,
@@ -133,7 +314,6 @@ public class AccountExportService
{
_logger.LogWarning(ex, "Session file is locked (attempt {Attempt}/{Max}) for account {AccountId}: {Path}", attempt, maxAttempts, accountId, sessionPath);
// 尝试释放本进程内的文件锁(WTelegramClient 会锁 session)
try
{
await _clientPool.RemoveClientAsync(accountId);
@@ -147,12 +327,7 @@ public class AccountExportService
}
}
// 最终失败:写出说明文件,不抛出导致整个导出失败
var warn = zip.CreateEntry($"{safeFolder}/WARN.txt", CompressionLevel.Fastest);
await using var warnStream = warn.Open();
await using var warnWriter = new StreamWriter(warnStream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
await warnWriter.WriteLineAsync($"session 文件被占用无法读取:{sessionPath}");
await warnWriter.WriteLineAsync("建议:先在面板停止该账号的 Telegram 客户端(或重启应用)后再导出。");
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN.txt", $"session 文件被占用无法读取:{sessionPath}\n建议:先在面板停止该账号的 Telegram 客户端(或重启应用)后再导出。");
}
private string ResolveSessionPath(Account account)
@@ -168,7 +343,6 @@ public class AccountExportService
if (Path.IsPathRooted(sessionPath))
return Path.GetFullPath(sessionPath);
// SessionPath 通常存的是相对路径(例如 sessions/<phone>.session),以 ContentRoot 作为基准更稳定
var combined = Path.GetFullPath(Path.Combine(_environment.ContentRootPath, sessionPath));
if (File.Exists(combined))
return combined;