mirror of
https://github.com/moeacgx/Telegram-Panel.git
synced 2026-05-07 06:23:50 +08:00
feat: 导出账号时自动生成独立新 session
- 导出前通过程序内 QR 登录流程自动申请新的独立授权 - 导出的 session 和 tdata 基于新授权生成,避免 AUTH_KEY_DUPLICATED 冲突 - 更新导出弹窗和导出说明文案
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user