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