feat: 导出账号时自动生成独立新 session

- 导出前通过程序内 QR 登录流程自动申请新的独立授权

- 导出的 session 和 tdata 基于新授权生成,避免 AUTH_KEY_DUPLICATED 冲突

- 更新导出弹窗和导出说明文案
This commit is contained in:
meoacgx
2026-03-17 00:20:06 +08:00
parent 8ac2ad80fc
commit 1649bcea2e
2 changed files with 264 additions and 24 deletions

View File

@@ -6,10 +6,13 @@
<MudText Typo="Typo.body2" Class="mt-2">
请选择导出格式:
</MudText>
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
导出时会自动为每个账号生成一份独立新 session避免和面板当前在线 session 冲突。
</MudAlert>
<MudPaper Class="pa-3 mt-3" Outlined="true">
<MudText Typo="Typo.subtitle2">Telethon默认</MudText>
<MudText Typo="Typo.caption">每个账号导出 `.json + .session (+2fa.txt)`,适合现有批量导入流程。</MudText>
<MudText Typo="Typo.caption">每个账号导出独立 `.json + .session (+2fa.txt)`,适合现有批量导入流程。</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-2" OnClick="ExportTelethon">
导出 Telethon
</MudButton>
@@ -17,7 +20,7 @@
<MudPaper Class="pa-3 mt-3" Outlined="true">
<MudText Typo="Typo.subtitle2">Tdata</MudText>
<MudText Typo="Typo.caption">每个账号额外导出 `tdata/`,并同时保留 `.json + .session (+2fa.txt)`。</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>

View File

@@ -1,10 +1,13 @@
using System.IO.Compression;
using System.Collections.Concurrent;
using System.Text;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using TelegramPanel.Core.Interfaces;
using TelegramPanel.Core.Services.Telegram;
using TelegramPanel.Data.Entities;
using TL;
using WTelegram;
namespace TelegramPanel.Web.Services;
@@ -51,19 +54,19 @@ public class AccountExportService
var safeFolder = BuildSafeFolderName(phone, account.Id);
_ = zip.CreateEntry($"{safeFolder}/");
await using var exportSession = await PrepareIndependentExportSessionAsync(account, cancellationToken);
try
{
var sessionPath = ResolveSessionPath(account);
var hasSession = false;
if (!File.Exists(sessionPath))
if (!exportSession.Ok || string.IsNullOrWhiteSpace(exportSession.SessionPath))
{
_logger.LogWarning("Session file missing for account {AccountId}: {Path}", account.Id, sessionPath);
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN.txt", $"未找到 session 文件:{sessionPath}");
_logger.LogWarning("Failed to prepare independent export session for account {AccountId}: {Error}", account.Id, exportSession.Error);
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN.txt", $"无法生成独立 session{exportSession.Error}");
}
else
{
await CopySessionWithRetryAsync(zip, safeFolder, sessionPath, account.Id, cancellationToken);
await CopySessionWithRetryAsync(zip, safeFolder, exportSession.SessionPath, account.Id, cancellationToken, releaseClientOnRetry: false);
hasSession = true;
}
@@ -81,7 +84,7 @@ public class AccountExportService
}
else
{
var tdataResult = await TryAddTdataPackageAsync(zip, safeFolder, account, sessionPath, cancellationToken);
var tdataResult = await TryAddTdataPackageAsync(zip, safeFolder, account, exportSession.SessionPath!, cancellationToken, releaseClientOnRetry: false);
if (!tdataResult.Ok)
await WriteTextEntryAsync(zip, $"{safeFolder}/WARN_TDATA.txt", $"tdata 生成失败:{tdataResult.Error}");
}
@@ -108,6 +111,7 @@ public class AccountExportService
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("默认行为:导出前会自动生成一份独立新 session避免与面板当前在线 session 发生 AUTH_KEY_DUPLICATED 冲突。");
await writer.WriteLineAsync("通用结构:每个账号一个子文件夹,至少包含 .json + .session如保存了二级密码则额外包含 2fa.txt。");
if (format == AccountExportFormat.Tdata)
await writer.WriteLineAsync("tdata 结构:每个账号目录下额外包含 tdata/ 子目录key_datas / D877F783D5D3EF8C*)。");
@@ -128,6 +132,7 @@ public class AccountExportService
first_name = account.Nickname,
api_id = account.ApiId,
api_hash = account.ApiHash,
session_mode = "independent",
exported_at_utc = DateTime.UtcNow
}, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
@@ -145,7 +150,8 @@ public class AccountExportService
string safeFolder,
Account account,
string sourceSessionPath,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool releaseClientOnRetry = true)
{
string? tempSessionPath = null;
string? tempTdataDir = null;
@@ -163,7 +169,8 @@ public class AccountExportService
sourceSessionPath: sourceSessionPath,
targetSessionPath: tempSessionPath,
accountId: account.Id,
cancellationToken: cancellationToken);
cancellationToken: cancellationToken,
releaseClientOnRetry: releaseClientOnRetry);
var telethonResult = SessionDataConverter.TryCreateTelethonStringSessionFromWTelegramSessionFile(
sessionPath: tempSessionPath,
@@ -225,7 +232,8 @@ public class AccountExportService
string sourceSessionPath,
string targetSessionPath,
int accountId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool releaseClientOnRetry = true)
{
const int maxAttempts = 3;
Directory.CreateDirectory(Path.GetDirectoryName(targetSessionPath) ?? Path.GetTempPath());
@@ -244,13 +252,16 @@ public class AccountExportService
{
_logger.LogWarning(ex, "Session file is locked while preparing tdata export (attempt {Attempt}/{Max}) for account {AccountId}: {Path}",
attempt, maxAttempts, accountId, sourceSessionPath);
try
if (releaseClientOnRetry)
{
await _clientPool.RemoveClientAsync(accountId);
}
catch (Exception removeEx)
{
_logger.LogDebug(removeEx, "Failed to remove client for account {AccountId} while preparing tdata export", accountId);
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);
@@ -296,7 +307,8 @@ public class AccountExportService
string safeFolder,
string sessionPath,
int accountId,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool releaseClientOnRetry = true)
{
const int maxAttempts = 3;
for (var attempt = 1; attempt <= maxAttempts; attempt++)
@@ -314,13 +326,16 @@ public class AccountExportService
{
_logger.LogWarning(ex, "Session file is locked (attempt {Attempt}/{Max}) for account {AccountId}: {Path}", attempt, maxAttempts, accountId, sessionPath);
try
if (releaseClientOnRetry)
{
await _clientPool.RemoveClientAsync(accountId);
}
catch (Exception removeEx)
{
_logger.LogDebug(removeEx, "Failed to remove client for account {AccountId} while exporting", accountId);
try
{
await _clientPool.RemoveClientAsync(accountId);
}
catch (Exception removeEx)
{
_logger.LogDebug(removeEx, "Failed to remove client for account {AccountId} while exporting", accountId);
}
}
await Task.Delay(200, cancellationToken);
@@ -350,6 +365,228 @@ public class AccountExportService
return Path.GetFullPath(sessionPath);
}
private async Task<PreparedExportSession> PrepareIndependentExportSessionAsync(Account account, CancellationToken cancellationToken)
{
if (account.ApiId <= 0)
return PreparedExportSession.Fail("账号缺少 ApiId无法生成独立 session");
var apiHash = (account.ApiHash ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(apiHash))
return PreparedExportSession.Fail("账号缺少 ApiHash无法生成独立 session");
var sourceSessionPath = ResolveSessionPath(account);
if (!File.Exists(sourceSessionPath))
return PreparedExportSession.Fail($"源 session 文件不存在:{sourceSessionPath}");
var tempSessionPath = Path.Combine(Path.GetTempPath(), $"telegram-panel-export-independent-{Guid.NewGuid():N}.session");
var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
linkedCts.CancelAfter(TimeSpan.FromSeconds(60));
var acceptTasks = new ConcurrentBag<Task>();
Exception? acceptError = null;
var keepTempSession = false;
using var acceptLock = new SemaphoreSlim(1, 1);
try
{
var sourceClient = await _clientPool.GetOrCreateClientAsync(
account.Id,
account.ApiId,
apiHash,
sourceSessionPath,
sessionKey: apiHash,
phoneNumber: account.Phone,
userId: account.UserId > 0 ? account.UserId : null);
if (sourceClient.User == null)
return PreparedExportSession.Fail("当前账号未处于已登录状态,无法生成独立 session");
string Config(string what)
{
var normalizedPhone = NormalizePhone(account.Phone);
return what switch
{
"api_id" => account.ApiId.ToString(),
"api_hash" => apiHash,
"session_pathname" => tempSessionPath,
"session_key" => apiHash,
"phone_number" => string.IsNullOrWhiteSpace(normalizedPhone) ? null! : normalizedPhone,
_ => null!
};
}
await using var builder = new Client(Config);
var user = await builder.LoginWithQRCode(
qrDisplay: loginUrl =>
{
var acceptTask = AcceptLoginTokenAsync(
sourceClient,
loginUrl,
linkedCts.Token,
acceptLock,
ex =>
{
acceptError = ex;
try { linkedCts.Cancel(); } catch { }
});
acceptTasks.Add(acceptTask);
},
except_ids: Array.Empty<long>(),
logoutFirst: false,
ct: linkedCts.Token);
await AwaitAcceptTasksAsync(acceptTasks);
if (user == null)
return PreparedExportSession.Fail("Telegram 未返回新 session 的登录结果");
if (account.UserId > 0 && user.id != account.UserId)
return PreparedExportSession.Fail($"新 session 登录到了错误账号(期望 {account.UserId},实际 {user.id}");
_logger.LogInformation(
"Generated independent export session for account {AccountId}, userId={UserId}",
account.Id,
user.id);
keepTempSession = true;
return PreparedExportSession.Success(tempSessionPath);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return PreparedExportSession.Fail(acceptError?.Message ?? "生成独立 session 超时");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to generate independent export session for account {AccountId}", account.Id);
return PreparedExportSession.Fail(ex.Message);
}
finally
{
linkedCts.Dispose();
if (!keepTempSession)
TryDeleteFile(tempSessionPath);
}
}
private async Task AcceptLoginTokenAsync(
Client sourceClient,
string loginUrl,
CancellationToken cancellationToken,
SemaphoreSlim acceptLock,
Action<Exception> onError)
{
try
{
var token = ParseLoginToken(loginUrl);
await acceptLock.WaitAsync(cancellationToken);
try
{
await sourceClient.Auth_AcceptLoginToken(token);
}
finally
{
acceptLock.Release();
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// ignore
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to accept QR login token while generating independent export session");
onError(ex);
}
}
private static async Task AwaitAcceptTasksAsync(ConcurrentBag<Task> acceptTasks)
{
var tasks = acceptTasks.ToArray();
if (tasks.Length == 0)
return;
try
{
await Task.WhenAll(tasks);
}
catch
{
// 具体异常已在各自任务中记录,这里避免覆盖主流程异常
}
}
private static byte[] ParseLoginToken(string loginUrl)
{
if (string.IsNullOrWhiteSpace(loginUrl))
throw new InvalidOperationException("QR 登录链接为空");
var tokenMarker = "token=";
var idx = loginUrl.IndexOf(tokenMarker, StringComparison.OrdinalIgnoreCase);
if (idx < 0)
throw new InvalidOperationException("QR 登录链接中缺少 token 参数");
var tokenEncoded = loginUrl[(idx + tokenMarker.Length)..];
var ampIdx = tokenEncoded.IndexOf('&');
if (ampIdx >= 0)
tokenEncoded = tokenEncoded[..ampIdx];
tokenEncoded = Uri.UnescapeDataString(tokenEncoded);
if (string.IsNullOrWhiteSpace(tokenEncoded))
throw new InvalidOperationException("QR 登录 token 为空");
return DecodeBase64Url(tokenEncoded);
}
private static byte[] DecodeBase64Url(string input)
{
var s = input.Replace('-', '+').Replace('_', '/');
var mod = s.Length % 4;
if (mod == 2) s += "==";
else if (mod == 3) s += "=";
else if (mod == 1) throw new FormatException("base64url token 长度非法");
return Convert.FromBase64String(s);
}
private static void TryDeleteFile(string path)
{
try
{
if (!string.IsNullOrWhiteSpace(path) && File.Exists(path))
File.Delete(path);
}
catch
{
// ignore
}
}
private sealed class PreparedExportSession : IAsyncDisposable
{
private PreparedExportSession(string? sessionPath, string? error, bool isTemporary)
{
SessionPath = sessionPath;
Error = error;
IsTemporary = isTemporary;
}
public string? SessionPath { get; }
public string? Error { get; }
public bool IsTemporary { get; }
public bool Ok => string.IsNullOrWhiteSpace(Error) && !string.IsNullOrWhiteSpace(SessionPath);
public static PreparedExportSession Success(string sessionPath) => new(sessionPath, null, true);
public static PreparedExportSession Fail(string error) => new(null, error, false);
public ValueTask DisposeAsync()
{
if (IsTemporary && !string.IsNullOrWhiteSpace(SessionPath))
TryDeleteFile(SessionPath);
return ValueTask.CompletedTask;
}
}
private static string BuildSafeFolderName(string phone, int accountId)
{
var digits = NormalizePhone(phone);