diff --git a/Dockerfile b/Dockerfile index 9a96d8b..645657c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,10 +6,17 @@ COPY . . RUN dotnet restore "TelegramPanel.sln" RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore +# tdata 运行时依赖(避免在容器内通过 apt 安装 Node) +FROM node:20-bookworm-slim AS tdata-runtime +WORKDIR /opt/telegram-panel-tdata-runtime +RUN printf '{\n "name": "telegram-panel-tdata-runtime",\n "private": true,\n "type": "module"\n}\n' > package.json \ + && npm install --silent @mtcute/convert @mtcute/node + FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS final WORKDIR /app ENV ASPNETCORE_URLS=http://+:5000 +ENV TELEGRAM_PANEL_TDATA_RUNTIME_DIR=/app/tdata-runtime EXPOSE 5000 # 持久化目录:/data(通过 docker-compose 挂载) @@ -23,6 +30,10 @@ RUN mkdir -p /data /data/sessions /data/logs \ && ln -s /data/appsettings.local.json /app/appsettings.local.json || true COPY --from=build /app/publish . +COPY --from=tdata-runtime /usr/local /usr/local +COPY --from=tdata-runtime /opt/telegram-panel-tdata-runtime /app/tdata-runtime + +RUN node -v && npm -v ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] diff --git a/Dockerfile.local b/Dockerfile.local index afd43ba..f9e6f7a 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -6,6 +6,12 @@ COPY . . RUN dotnet restore "TelegramPanel.sln" RUN dotnet publish "src/TelegramPanel.Web/TelegramPanel.Web.csproj" -c Release -o /app/publish --no-restore +# tdata 运行时依赖(避免在容器内通过 apt 安装 Node) +FROM node:20-bookworm-slim AS tdata-runtime +WORKDIR /opt/telegram-panel-tdata-runtime +RUN printf '{\n "name": "telegram-panel-tdata-runtime",\n "private": true,\n "type": "module"\n}\n' > package.json \ + && npm install --silent @mtcute/convert @mtcute/node + # 说明: # - 正常生产部署建议使用 mcr.microsoft.com/dotnet/aspnet:8.0 作为运行时镜像(更小) # - 若当前网络环境导致 Docker/WSL 无法拉取 aspnet 镜像,可临时使用本文件(运行时使用 sdk 镜像) @@ -13,6 +19,7 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS final WORKDIR /app ENV ASPNETCORE_URLS=http://+:5000 +ENV TELEGRAM_PANEL_TDATA_RUNTIME_DIR=/app/tdata-runtime EXPOSE 5000 # 持久化目录:/data(通过 docker-compose 挂载) @@ -26,6 +33,9 @@ RUN mkdir -p /data /data/sessions /data/logs \ && ln -s /data/appsettings.local.json /app/appsettings.local.json || true COPY --from=build /app/publish . +COPY --from=tdata-runtime /usr/local /usr/local +COPY --from=tdata-runtime /opt/telegram-panel-tdata-runtime /app/tdata-runtime + +RUN node -v && npm -v ENTRYPOINT ["dotnet", "TelegramPanel.Web.dll"] - diff --git a/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs b/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs index ce4241b..7d12bca 100644 --- a/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs +++ b/src/TelegramPanel.Core/Interfaces/ISessionImporter.cs @@ -15,7 +15,8 @@ public interface ISessionImporter int apiId, string apiHash, long? userId = null, - string? phoneHint = null); + string? phoneHint = null, + string? sessionKey = null); /// /// 批量导入Session文件 diff --git a/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs b/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs index bd8fd78..aa4d8ce 100644 --- a/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/AccountImportService.cs @@ -245,6 +245,15 @@ public class AccountImportService var jsonFiles = Directory.EnumerateFiles(extractDir, "*.json", SearchOption.AllDirectories).ToList(); if (jsonFiles.Count == 0) { + var tdataDirs = FindTdataDirectories(extractDir); + if (tdataDirs.Count > 0) + { + results.AddRange(await ImportFromTdataDirectoriesAsync(tdataDirs, categoryId, twoFactorPassword)); + var tdataSuccess = results.Count(r => r.Success); + _logger.LogInformation("Tdata import completed: {Success}/{Total} successful", tdataSuccess, results.Count); + return results; + } + results.Add(new ImportResult(false, null, null, null, null, "压缩包内未找到任何 .json 文件")); return results; } @@ -477,6 +486,231 @@ public class AccountImportService string extractDirFallback() => Path.GetTempPath(); } + private async Task> ImportFromTdataDirectoriesAsync( + IReadOnlyCollection tdataDirectories, + int? categoryId, + string? twoFactorPassword) + { + var results = new List(); + + if (!TryGetGlobalTelegramApi(out var apiId, out var apiHash)) + { + results.Add(new ImportResult( + false, + null, + null, + null, + null, + "检测到 tdata 数据包,但系统未配置全局 Telegram API(ApiId/ApiHash);请先到【系统设置】配置后再导入")); + return results; + } + + var importedPhones = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var tdataDir in tdataDirectories.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var result = await ImportFromTdataDirectoryAsync(tdataDir, apiId, apiHash, categoryId, twoFactorPassword); + var normalizedPhone = PhoneNumberFormatter.NormalizeToDigits(result.Phone); + if (!string.IsNullOrWhiteSpace(normalizedPhone)) + { + result = result with { Phone = normalizedPhone }; + if (!importedPhones.Add(normalizedPhone)) + { + results.Add(new ImportResult(false, normalizedPhone, result.UserId, result.Username, result.SessionPath, "重复账号已跳过")); + continue; + } + } + + results.Add(result); + } + + return results; + } + + private async Task ImportFromTdataDirectoryAsync( + string tdataDir, + int apiId, + string apiHash, + int? categoryId, + string? twoFactorPassword) + { + string? tempSessionPath = null; + try + { + var converted = await TdataSessionBridge.TryConvertToTelethonStringSessionAsync(tdataDir, _logger); + if (!converted.Ok || string.IsNullOrWhiteSpace(converted.SessionString)) + { + return new ImportResult(false, null, converted.UserId, null, null, + $"tdata 解析失败({tdataDir}):{converted.Error ?? "未知原因"}"); + } + + var phoneSeed = converted.UserId?.ToString() ?? Path.GetFileName(tdataDir); + if (string.IsNullOrWhiteSpace(phoneSeed)) + phoneSeed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + + tempSessionPath = Path.Combine(Path.GetTempPath(), $"telegram-panel-tdata-{Guid.NewGuid():N}.session"); + var writeResult = await SessionDataConverter.TryCreateWTelegramSessionFromSessionStringAsync( + sessionString: converted.SessionString, + apiId: apiId, + apiHash: apiHash, + targetSessionPath: tempSessionPath, + phone: phoneSeed, + userId: converted.UserId, + logger: _logger); + if (!writeResult.Ok) + { + return new ImportResult(false, null, converted.UserId, null, null, + $"tdata 会话转换失败:{writeResult.Reason ?? "未知原因"}"); + } + + var imported = await _sessionImporter.ImportFromSessionFileAsync( + tempSessionPath, + apiId, + apiHash, + userId: converted.UserId, + phoneHint: phoneSeed, + sessionKey: apiHash); + if (!imported.Success) + { + return new ImportResult( + false, + imported.Phone, + imported.UserId ?? converted.UserId, + imported.Username, + imported.SessionPath, + $"tdata 导入失败:{imported.Error ?? "未知原因"}"); + } + + return await PersistImportedSessionAsync(imported, apiId, apiHash, categoryId, twoFactorPassword); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to import tdata directory: {TdataDir}", tdataDir); + return new ImportResult(false, null, null, null, null, $"tdata 导入异常:{FormatException(ex)}"); + } + finally + { + try + { + if (!string.IsNullOrWhiteSpace(tempSessionPath) && File.Exists(tempSessionPath)) + File.Delete(tempSessionPath); + } + catch + { + // 忽略临时文件删除失败 + } + } + } + + private async Task PersistImportedSessionAsync( + ImportResult result, + int apiId, + string apiHash, + int? categoryId, + string? twoFactorPassword) + { + if (!result.Success || !result.UserId.HasValue) + return result; + + try + { + var phone = PhoneNumberFormatter.NormalizeToDigits(result.Phone); + if (string.IsNullOrWhiteSpace(phone)) + throw new InvalidOperationException("导入结果缺少有效手机号"); + + var existing = await _accountManagement.GetAccountByPhoneAsync(phone); + if (existing != null) + { + existing.UserId = result.UserId.Value; + existing.Username = result.Username; + existing.SessionPath = result.SessionPath!; + existing.ApiId = apiId; + existing.ApiHash = apiHash.Trim(); + existing.IsActive = true; + existing.CategoryId = categoryId ?? existing.CategoryId; + existing.LastSyncAt = DateTime.UtcNow; + if (!string.IsNullOrWhiteSpace(twoFactorPassword)) + existing.TwoFactorPassword = twoFactorPassword.Trim(); + await _accountManagement.UpdateAccountAsync(existing); + } + else + { + var account = new Account + { + Phone = phone, + UserId = result.UserId.Value, + Username = result.Username, + SessionPath = result.SessionPath!, + ApiId = apiId, + ApiHash = apiHash.Trim(), + IsActive = true, + CategoryId = categoryId, + TwoFactorPassword = string.IsNullOrWhiteSpace(twoFactorPassword) ? null : twoFactorPassword.Trim(), + CreatedAt = DateTime.UtcNow, + LastSyncAt = DateTime.UtcNow + }; + + await _accountManagement.CreateAccountAsync(account); + } + + _logger.LogInformation("Account saved to database: {Phone}", phone); + return result with { Phone = phone }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to save account to database: {Phone}", result.Phone); + return new ImportResult( + false, + result.Phone, + result.UserId, + result.Username, + result.SessionPath, + $"Session 已导入,但数据库保存失败:{FormatException(ex)}"); + } + } + + private bool TryGetGlobalTelegramApi(out int apiId, out string apiHash) + { + apiHash = (_configuration["Telegram:ApiHash"] ?? string.Empty).Trim(); + return int.TryParse(_configuration["Telegram:ApiId"], out apiId) + && apiId > 0 + && !string.IsNullOrWhiteSpace(apiHash); + } + + private static List FindTdataDirectories(string rootDirectory) + { + var result = new List(); + if (LooksLikeTdataDirectory(rootDirectory)) + result.Add(rootDirectory); + + foreach (var dir in Directory.EnumerateDirectories(rootDirectory, "*", SearchOption.AllDirectories)) + { + if (LooksLikeTdataDirectory(dir)) + result.Add(dir); + } + + return result + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool LooksLikeTdataDirectory(string directory) + { + try + { + if (!Directory.Exists(directory)) + return false; + + var hasKeyFile = File.Exists(Path.Combine(directory, "key_datas")) + || File.Exists(Path.Combine(directory, "key_data")); + var hasTdataData = Directory.EnumerateFileSystemEntries(directory, "D877F783D5D3EF8C*", SearchOption.TopDirectoryOnly).Any(); + return hasKeyFile || hasTdataData; + } + catch + { + return false; + } + } + /// /// 尝试从目录中读取 2fa.txt 文件内容作为二级密码 /// diff --git a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs index 4236c1b..9dd86af 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotTelegramService.cs @@ -824,7 +824,8 @@ public sealed record BotAdminRights( IReadOnlyList channelTelegramIds, long userId, bool permanentBan, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? perChatCallback = null) { var bot = await _botManagement.GetBotAsync(botId) ?? throw new InvalidOperationException($"机器人不存在:{botId}"); @@ -936,6 +937,12 @@ public sealed record BotAdminRights( } } + if (perChatCallback != null) + { + failures.TryGetValue(chatId, out var err); + await perChatCallback(chatId, err, ok, failures.Count); + } + // 降速:避免 Bot API 429 if (delayMs > 0 && index < distinctIds.Count - 1) { diff --git a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs index 66d8d65..55c72a5 100644 --- a/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs +++ b/src/TelegramPanel.Core/Services/Telegram/BotUpdateHub.cs @@ -28,7 +28,12 @@ public sealed class BotUpdateHub : IAsyncDisposable private readonly Dictionary _pollersByToken = new(StringComparer.Ordinal); // Webhook 模式下的接收器(token -> receiver),不启动轮询 - private readonly Dictionary _webhookReceivers = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary _webhookReceivers = new(StringComparer.Ordinal); + + private static readonly TimeSpan WebhookTokenCacheTtl = TimeSpan.FromMinutes(5); + private readonly SemaphoreSlim _webhookTokenCacheGate = new(1, 1); + private DateTimeOffset _webhookTokenCacheBuiltAtUtc = DateTimeOffset.MinValue; + private Dictionary _botTokenByWebhookPathToken = new(StringComparer.Ordinal); public BotUpdateHub( IServiceScopeFactory scopeFactory, @@ -57,10 +62,19 @@ public sealed class BotUpdateHub : IAsyncDisposable if (string.IsNullOrWhiteSpace(token)) return false; + if (_webhookReceivers.TryGetValue(token, out var fastReceiver)) + { + fastReceiver.Inject(update); + return true; + } + + BotWebhookReceiver receiver; + + // 仅在“首次遇到某个 token”时加全局锁,避免高频 webhook 进入串行瓶颈。 await _gate.WaitAsync(cancellationToken); try { - if (!_webhookReceivers.TryGetValue(token, out var receiver)) + if (!_webhookReceivers.TryGetValue(token, out receiver!)) { // 验证 token 有效性并创建 receiver using var scope = _scopeFactory.CreateScope(); @@ -72,14 +86,76 @@ public sealed class BotUpdateHub : IAsyncDisposable receiver = new BotWebhookReceiver(bot.Id, token, _scopeFactory, _logger); _webhookReceivers[token] = receiver; } - - receiver.Inject(update); - return true; } finally { _gate.Release(); } + + receiver.Inject(update); + return true; + } + + /// + /// 将 Webhook 路径中的 token(推荐为 SHA256(token))解析为真实的 bot token。 + /// + public async Task ResolveBotTokenFromWebhookPathAsync(string pathToken, CancellationToken cancellationToken) + { + pathToken = (pathToken ?? "").Trim(); + if (string.IsNullOrWhiteSpace(pathToken)) + return null; + + // 兼容:若仍使用明文 bot token 作为路径(不推荐),直接返回。 + if (WebhookTokenHelper.IsLikelyPlainBotToken(pathToken)) + return pathToken; + + if (!WebhookTokenHelper.IsSha256Hex(pathToken)) + return null; + + var builtAt = _webhookTokenCacheBuiltAtUtc; + if (builtAt != DateTimeOffset.MinValue + && DateTimeOffset.UtcNow - builtAt < WebhookTokenCacheTtl + && _botTokenByWebhookPathToken.TryGetValue(pathToken, out var cached)) + { + return cached; + } + + await _webhookTokenCacheGate.WaitAsync(cancellationToken); + try + { + builtAt = _webhookTokenCacheBuiltAtUtc; + if (builtAt == DateTimeOffset.MinValue || DateTimeOffset.UtcNow - builtAt >= WebhookTokenCacheTtl) + await RebuildWebhookTokenCacheAsync(cancellationToken); + + return _botTokenByWebhookPathToken.TryGetValue(pathToken, out var token) ? token : null; + } + finally + { + _webhookTokenCacheGate.Release(); + } + } + + private async Task RebuildWebhookTokenCacheAsync(CancellationToken cancellationToken) + { + using var scope = _scopeFactory.CreateScope(); + var botRepo = scope.ServiceProvider.GetRequiredService(); + var bots = await botRepo.GetAllAsync(); + + var map = new Dictionary(StringComparer.Ordinal); + foreach (var bot in bots) + { + if (!bot.IsActive || string.IsNullOrWhiteSpace(bot.Token)) + continue; + + var token = bot.Token.Trim(); + if (string.IsNullOrWhiteSpace(token)) + continue; + + map[WebhookTokenHelper.ToWebhookPathToken(token)] = token; + } + + _botTokenByWebhookPathToken = map; + _webhookTokenCacheBuiltAtUtc = DateTimeOffset.UtcNow; } /// diff --git a/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs b/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs index 99ae924..b0cfcb9 100644 --- a/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs +++ b/src/TelegramPanel.Core/Services/Telegram/SessionImporter.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Configuration; using TelegramPanel.Core.Interfaces; +using TL; using WTelegram; namespace TelegramPanel.Core.Services.Telegram; @@ -24,7 +25,8 @@ public class SessionImporter : ISessionImporter int apiId, string apiHash, long? userId = null, - string? phoneHint = null) + string? phoneHint = null, + string? sessionKey = null) { if (!File.Exists(filePath)) { @@ -62,21 +64,36 @@ public class SessionImporter : ISessionImporter "api_id" => apiId.ToString(), "api_hash" => apiHash, "session_pathname" => targetPath, + "session_key" => string.IsNullOrWhiteSpace(sessionKey) ? null! : sessionKey, _ => null! }; using var client = new Client(Config); await client.ConnectAsync(); - if (client.User != null) + var self = client.User; + if (self == null) { - _logger.LogInformation("Session imported successfully for user {UserId}", client.User.id); + try + { + var users = await client.Users_GetUsers(InputUser.Self); + self = users.OfType().FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch self user after session connect: {SessionPath}", targetPath); + } + } + + if (self != null) + { + _logger.LogInformation("Session imported successfully for user {UserId}", self.id); return new ImportResult( Success: true, - Phone: client.User.phone, - UserId: client.User.id, - Username: client.User.MainUsername, + Phone: self.phone, + UserId: self.id, + Username: self.MainUsername, SessionPath: targetPath ); } @@ -135,17 +152,31 @@ public class SessionImporter : ISessionImporter using var client = new Client(Config); await client.ConnectAsync(); - if (client.User != null) + var self = client.User; + if (self == null) + { + try + { + var users = await client.Users_GetUsers(InputUser.Self); + self = users.OfType().FirstOrDefault(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch self user after string session connect: {SessionPath}", sessionPath); + } + } + + if (self != null) { // 重命名为手机号 - var newPath = Path.Combine(sessionsPath, $"{client.User.phone}.session"); + var newPath = Path.Combine(sessionsPath, $"{self.phone}.session"); File.Move(sessionPath, newPath, overwrite: true); return new ImportResult( Success: true, - Phone: client.User.phone, - UserId: client.User.id, - Username: client.User.MainUsername, + Phone: self.phone, + UserId: self.id, + Username: self.MainUsername, SessionPath: newPath ); } diff --git a/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs new file mode 100644 index 0000000..fef40b5 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/TdataSessionBridge.cs @@ -0,0 +1,305 @@ +using System.Diagnostics; +using System.ComponentModel; +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.Logging; + +namespace TelegramPanel.Core.Services.Telegram; + +internal static class TdataSessionBridge +{ + private static readonly SemaphoreSlim SetupLock = new(1, 1); + private static readonly string RuntimeDir = ResolveRuntimeDir(); + + private const int NodeCheckTimeoutMs = 10_000; + private const int SetupTimeoutMs = 180_000; + private const int ConvertTimeoutMs = 60_000; + + private static string ResolveRuntimeDir() + { + var fromEnv = Environment.GetEnvironmentVariable("TELEGRAM_PANEL_TDATA_RUNTIME_DIR"); + if (!string.IsNullOrWhiteSpace(fromEnv)) + return fromEnv.Trim(); + return Path.Combine(Path.GetTempPath(), "telegram-panel-tdata-runtime"); + } + + private const string NodeScript = """ +import { convertFromTdata, convertToTelethonSession } from '@mtcute/convert'; + +const inputPath = process.argv[process.argv.length - 1]; + +try { + const session = await convertFromTdata({ path: inputPath, ignoreVersion: true }); + const sessionString = convertToTelethonSession(session); + const userId = session?.self?.userId ?? null; + console.log(JSON.stringify({ ok: true, sessionString, userId })); +} 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) + { + public static ConvertResult Success(string sessionString, long? userId) => new(true, sessionString, userId, null); + public static ConvertResult Fail(string error) => new(false, null, null, string.IsNullOrWhiteSpace(error) ? "未知错误" : error.Trim()); + } + + public static async Task TryConvertToTelethonStringSessionAsync( + string tdataDirectory, + ILogger logger, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(tdataDirectory) || !Directory.Exists(tdataDirectory)) + return ConvertResult.Fail("tdata 目录不存在"); + + var runtimeReady = await EnsureRuntimeReadyAsync(logger, cancellationToken); + if (!runtimeReady.Ok) + return ConvertResult.Fail(runtimeReady.Error ?? "tdata 运行环境初始化失败"); + + try + { + var run = await RunProcessAsync( + fileName: "node", + arguments: new[] { "--input-type=module", "-e", NodeScript, "--", Path.GetFullPath(tdataDirectory) }, + 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 ConvertResult.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 ConvertResult.Fail(err); + } + + if (!root.TryGetProperty("sessionString", out var sessionProp) || sessionProp.ValueKind != JsonValueKind.String) + return ConvertResult.Fail("转换输出缺少 sessionString"); + + var sessionString = (sessionProp.GetString() ?? string.Empty).Trim(); + if (string.IsNullOrWhiteSpace(sessionString)) + return ConvertResult.Fail("转换得到的 sessionString 为空"); + + long? userId = null; + if (root.TryGetProperty("userId", out var uidProp)) + { + if (uidProp.ValueKind == JsonValueKind.Number && uidProp.TryGetInt64(out var uid)) + userId = uid; + else if (uidProp.ValueKind == JsonValueKind.String && long.TryParse(uidProp.GetString(), out var uidStr)) + userId = uidStr; + } + + return ConvertResult.Success(sessionString, userId); + } + catch (TimeoutException ex) + { + logger.LogWarning(ex, "Tdata conversion timed out: {TdataDir}", tdataDirectory); + return ConvertResult.Fail("tdata 转换超时"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Tdata conversion failed: {TdataDir}", tdataDirectory); + return ConvertResult.Fail(ex.Message); + } + } + + private static async Task<(bool Ok, string? Error)> EnsureRuntimeReadyAsync(ILogger logger, CancellationToken cancellationToken) + { + await SetupLock.WaitAsync(cancellationToken); + try + { + Directory.CreateDirectory(RuntimeDir); + + if (HasRequiredPackages()) + return (true, null); + + var nodeCheck = await RunProcessAsync( + fileName: "node", + arguments: new[] { "--version" }, + workingDirectory: RuntimeDir, + timeoutMs: NodeCheckTimeoutMs, + cancellationToken: cancellationToken); + if (nodeCheck.ExitCode != 0) + { + var err = string.IsNullOrWhiteSpace(nodeCheck.StdErr) ? "无法执行 node 命令" : nodeCheck.StdErr.Trim(); + return (false, $"Node.js 不可用:{err}"); + } + + var packageJson = Path.Combine(RuntimeDir, "package.json"); + if (!File.Exists(packageJson)) + { + const string packageJsonContent = """ +{ + "name": "telegram-panel-tdata-runtime", + "private": true, + "type": "module" +} +"""; + await File.WriteAllTextAsync(packageJson, packageJsonContent, Encoding.UTF8, cancellationToken); + } + + if (!HasRequiredPackages()) + { + logger.LogInformation("Installing tdata converter runtime dependencies in {RuntimeDir}", RuntimeDir); + var install = await RunProcessAsync( + fileName: "npm", + arguments: new[] { "install", "--silent", "@mtcute/convert", "@mtcute/node" }, + workingDirectory: RuntimeDir, + timeoutMs: SetupTimeoutMs, + cancellationToken: cancellationToken); + if (install.ExitCode != 0) + { + var err = string.IsNullOrWhiteSpace(install.StdErr) ? install.StdOut : install.StdErr; + return (false, $"npm 安装 tdata 依赖失败:{(err ?? string.Empty).Trim()}"); + } + } + + if (!HasRequiredPackages()) + return (false, "tdata 依赖安装后仍不可用"); + + return (true, null); + } + catch (TimeoutException ex) + { + logger.LogWarning(ex, "Preparing tdata runtime timed out"); + return (false, "准备 tdata 运行环境超时"); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Preparing tdata runtime failed"); + return (false, ex.Message); + } + finally + { + SetupLock.Release(); + } + } + + private static bool HasRequiredPackages() + { + if (!Directory.Exists(RuntimeDir)) + return false; + + var convertPkg = Path.Combine(RuntimeDir, "node_modules", "@mtcute", "convert", "package.json"); + var nodePkg = Path.Combine(RuntimeDir, "node_modules", "@mtcute", "node", "package.json"); + return File.Exists(convertPkg) && File.Exists(nodePkg); + } + + private static string? PickJsonLine(string? stdout) + { + if (string.IsNullOrWhiteSpace(stdout)) + return null; + + var lines = stdout + .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries) + .Select(line => line.Trim()) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + for (var i = lines.Count - 1; i >= 0; i--) + { + var line = lines[i]; + if (line.StartsWith("{", StringComparison.Ordinal) && line.EndsWith("}", StringComparison.Ordinal)) + return line; + } + + return lines.Count > 0 ? lines[^1] : null; + } + + private readonly record struct ProcessResult(int ExitCode, string StdOut, string StdErr); + + private static async Task RunProcessAsync( + string fileName, + IReadOnlyCollection arguments, + string workingDirectory, + int timeoutMs, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(workingDirectory)) + throw new InvalidOperationException("进程工作目录不能为空"); + + Directory.CreateDirectory(workingDirectory); + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = fileName, + WorkingDirectory = workingDirectory, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + } + }; + + foreach (var argument in arguments) + process.StartInfo.ArgumentList.Add(argument); + + process.OutputDataReceived += (_, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) + stdOut.AppendLine(args.Data); + }; + process.ErrorDataReceived += (_, args) => + { + if (!string.IsNullOrWhiteSpace(args.Data)) + stdErr.AppendLine(args.Data); + }; + + try + { + if (!process.Start()) + throw new InvalidOperationException($"无法启动进程:{fileName}"); + } + catch (Win32Exception ex) + { + throw new InvalidOperationException( + $"无法启动进程 `{fileName}`,请确认容器/系统已安装并加入 PATH。原始错误:{ex.Message}", + ex); + } + + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeoutMs); + + try + { + await process.WaitForExitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + try + { + if (!process.HasExited) + process.Kill(entireProcessTree: true); + } + catch + { + // ignore kill failure + } + + throw new TimeoutException($"{fileName} 执行超时(>{timeoutMs}ms)"); + } + + return new ProcessResult(process.ExitCode, stdOut.ToString(), stdErr.ToString()); + } +} diff --git a/src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs b/src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs new file mode 100644 index 0000000..7eae1e0 --- /dev/null +++ b/src/TelegramPanel.Core/Services/Telegram/WebhookTokenHelper.cs @@ -0,0 +1,48 @@ +using System.Security.Cryptography; +using System.Text; + +namespace TelegramPanel.Core.Services.Telegram; + +public static class WebhookTokenHelper +{ + public static string ToWebhookPathToken(string botToken) + { + botToken = (botToken ?? "").Trim(); + if (string.IsNullOrWhiteSpace(botToken)) + throw new ArgumentException("botToken 不能为空", nameof(botToken)); + + var bytes = Encoding.UTF8.GetBytes(botToken); + var hash = SHA256.HashData(bytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static bool IsLikelyPlainBotToken(string value) + { + value = (value ?? "").Trim(); + if (value.Length == 0) + return false; + + // Telegram bot token 典型格式:{bot_id}:{token} + return value.Contains(':', StringComparison.Ordinal); + } + + public static bool IsSha256Hex(string value) + { + value = (value ?? "").Trim(); + if (value.Length != 64) + return false; + + foreach (var ch in value) + { + var isHex = + (ch >= '0' && ch <= '9') || + (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F'); + if (!isHex) + return false; + } + + return true; + } +} + diff --git a/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor new file mode 100644 index 0000000..d230e15 --- /dev/null +++ b/src/TelegramPanel.Web/Components/ModulePages/KickApiPage.razor @@ -0,0 +1,365 @@ +@namespace TelegramPanel.Web.Components.ModulePages +@inject TelegramPanel.Core.Services.BotManagementService BotManagement +@inject TelegramPanel.Core.Services.BatchTaskManagementService TaskManagement +@inject ISnackbar Snackbar +@inject NavigationManager Navigation +@using System.Text.Json +@using TelegramPanel.Core.BatchTasks +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.ExternalApi +@using Microsoft.AspNetCore.Components + +踢人/封禁 + + + + + 提交后会在后台执行,可到【任务中心】查看进度与结果。频道数量较多时可能需要较长时间。 + + + + 全部机器人(谨慎) + @foreach (var b in bots) + { + @b.Name + } + + + + 已解析 @userIds.Count 个用户 ID + + + + + + @if (selectedBotId != 0 && !useAllChats) + { + + 选择分类:@selectedCategories.Count / @categories.Count + + + 全选 + + + 清空 + + + + + + 分类 + 说明 + + + @context.Name + @(!string.IsNullOrWhiteSpace(context.Description) ? context.Description : "-") + + + 暂无分类 + + + + + + + } + + + + 提交任务 + + + 打开任务中心 + + + + + +@code +{ + [Parameter] public string ModuleId { get; set; } = ""; + [Parameter] public string PageKey { get; set; } = ""; + + private bool loading = true; + private List bots = new(); + private List categories = new(); + private int selectedBotId = 0; + private string userIdsText = ""; + private List userIds = new(); + private bool permanentBan; + private bool useAllChats = true; + private HashSet selectedCategories = new(); + private bool includeUncategorized; + private string chatIdsText = ""; + + private bool CanSubmit + => !loading + && userIds.Count > 0 + && (selectedBotId == 0 || bots.Any(x => x.Id == selectedBotId)) + && ( + selectedBotId == 0 + || useAllChats + || includeUncategorized + || selectedCategories.Count > 0 + || !string.IsNullOrWhiteSpace(chatIdsText)); + + protected override async Task OnInitializedAsync() + { + try + { + bots = (await BotManagement.GetAllBotsAsync()) + .Where(b => b.IsActive) + .OrderBy(b => b.Id) + .ToList(); + + categories = (await BotManagement.GetCategoriesAsync()) + .OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // 默认不对“全部机器人/全部频道”开火 + if (bots.Count > 0) + { + selectedBotId = bots[0].Id; + useAllChats = false; + } + } + catch (Exception ex) + { + Snackbar.Add($"加载机器人失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private async Task OnUserIdsChanged() + { + userIds = ParseLongIds(userIdsText); + await Task.CompletedTask; + } + + private async Task OnSelectedBotChanged(int id) + { + var previous = selectedBotId; + selectedBotId = id; + if (selectedBotId == 0) + useAllChats = true; + else if (previous == 0) + useAllChats = false; + + selectedCategories = new HashSet(); + includeUncategorized = false; + chatIdsText = ""; + await Task.CompletedTask; + } + + private async Task OnUseAllChatsChanged() + { + if (useAllChats) + { + selectedCategories = new HashSet(); + includeUncategorized = false; + chatIdsText = ""; + } + await Task.CompletedTask; + } + + private async Task SelectAllCategories() + { + selectedCategories = categories.ToHashSet(); + await Task.CompletedTask; + } + + private async Task ClearCategories() + { + selectedCategories = new HashSet(); + includeUncategorized = false; + await Task.CompletedTask; + } + + private async Task SubmitAsync() + { + if (!CanSubmit) + return; + + var botId = selectedBotId; + var runUseAllChats = botId == 0 ? true : useAllChats; + + loading = true; + try + { + var total = 0; + var targetChatIds = new List(); + + if (botId == 0) + { + foreach (var b in bots) + total += (await BotManagement.GetChatsAsync(b.Id)).Count(); + } + else + { + var chats = (await BotManagement.GetChatsAsync(botId)).ToList(); + if (runUseAllChats) + { + total = chats.Count; + } + else + { + var selectedIds = new HashSet(); + var selectedCategoryIdSet = selectedCategories.Select(x => x.Id).ToHashSet(); + + if (selectedCategoryIdSet.Count > 0 || includeUncategorized) + { + foreach (var c in chats) + { + if (c.CategoryId.HasValue) + { + if (selectedCategoryIdSet.Contains(c.CategoryId.Value)) + selectedIds.Add(c.TelegramId); + } + else + { + if (includeUncategorized) + selectedIds.Add(c.TelegramId); + } + } + } + + foreach (var id in ParseChatIds(chatIdsText)) + selectedIds.Add(id); + + if (selectedIds.Count > 0) + { + total = chats.Count(c => selectedIds.Contains(c.TelegramId)); + var knownIds = chats.Select(c => c.TelegramId).ToHashSet(); + targetChatIds = selectedIds + .Where(knownIds.Contains) + .OrderBy(x => x) + .ToList(); + } + } + } + + if (total <= 0) + { + Snackbar.Add("未匹配到任何可操作的频道/群组(请先同步 Bot 频道数据,或检查 chat_id 列表)", Severity.Warning); + return; + } + + var createdIds = new List(); + var failedCount = 0; + string? firstError = null; + + foreach (var uid in userIds) + { + try + { + var log = new KickTaskLog + { + ApiName = $"模块页面 (user={uid})", + BotId = botId, + UseAllChats = runUseAllChats, + ChatIds = runUseAllChats ? new List() : targetChatIds, + UserId = uid, + PermanentBan = permanentBan, + RequestedAtUtc = DateTime.UtcNow + }; + + var task = new BatchTask + { + TaskType = BatchTaskTypes.ExternalApiKick, + Total = total, + Completed = 0, + Failed = 0, + Config = JsonSerializer.Serialize(log, new JsonSerializerOptions { WriteIndented = true }) + }; + + var created = await TaskManagement.CreateTaskAsync(task); + createdIds.Add(created.Id); + } + catch (Exception ex) + { + failedCount++; + firstError ??= ex.Message; + } + } + + if (createdIds.Count == 0) + { + Snackbar.Add($"提交失败:{firstError ?? "未知错误"}", Severity.Error); + return; + } + + if (failedCount > 0) + Snackbar.Add($"已提交 {createdIds.Count} 个任务,{failedCount} 个失败(首个错误:{firstError})", Severity.Warning); + else + Snackbar.Add($"已提交 {createdIds.Count} 个任务。请到【任务中心】查看进度。", Severity.Success); + + Navigation.NavigateTo("/tasks"); + } + catch (Exception ex) + { + Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private static List ParseChatIds(string raw) + { + raw = (raw ?? "").Trim(); + if (raw.Length == 0) + return new List(); + + var parts = raw + .Replace("\r", "\n", StringComparison.Ordinal) + .Split(new[] { ',', ';', ' ', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var ids = new List(); + foreach (var p in parts) + { + if (long.TryParse(p, out var v) && v != 0) + ids.Add(v); + } + return ids; + } + + private static List ParseLongIds(string raw) + { + raw = (raw ?? "").Trim(); + if (raw.Length == 0) + return new List(); + + var parts = raw + .Replace("\r", "\n", StringComparison.Ordinal) + .Split(new[] { ',', ';', ' ', '\n', '\t' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + var set = new HashSet(); + foreach (var p in parts) + { + if (long.TryParse(p, out var v) && v > 0) + set.Add(v); + } + + return set.OrderBy(x => x).ToList(); + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor index 7082651..570d592 100644 --- a/src/TelegramPanel.Web/Components/Pages/AccountImport.razor +++ b/src/TelegramPanel.Web/Components/Pages/AccountImport.razor @@ -1,7 +1,17 @@ @page "/accounts/import" @inject AccountImportService AccountImportService +@inject AccountManagementService AccountManagement +@inject AccountCategoryManagementService CategoryManagement +@inject AccountTelegramToolsService AccountTelegramTools +@inject TelegramPanel.Core.Services.AccountRiskService RiskService @inject IConfiguration Configuration @inject ISnackbar Snackbar +@inject IDialogService DialogService +@using TelegramPanel.Core.Models +@using TelegramPanel.Core.Utils +@using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Components.Dialogs +@implements IDisposable 导入账号 - Telegram Panel @@ -20,8 +30,10 @@
  • 单账号:Zip 根目录直接包含一个 .json + 一个 .session
  • 批量导入:每个账号一个独立子文件夹,文件夹内包含一个 .json + 一个 .session
  • +
  • tdata 协议包:支持 Zip 内包含 tdata 目录(含 key_datas / D877F783D5D3EF8C*);首次导入会自动安装解析依赖
  • 二级密码:自动解析账号目录下的 2fa.txt 文件作为二级密码保存到数据库
+
提示:导入 tdata 需要先在【系统设置】配置全局 Telegram API(ApiId/ApiHash)。
批量结构示例:
xx.zip
   ├─ 8613111111111
@@ -168,6 +180,82 @@
     
 }
 
+@if (importedAccounts.Any())
+{
+    
+        
+            已导入账号(可批量操作)
+            
+            
+                清空
+            
+        
+        
+            
+                
+                    @SelectionToggleText
+                
+                
+                    刷新已选状态(可深度)
+                
+                
+                    踢出其他设备(已选)
+                
+
+                
+                    批量加群/订阅(已选)
+                    批量退群/退订(已选)
+                    修改二级密码(已选)
+                    批量换绑邮箱(找回+登录)(Cloud Mail)(已选)
+                    批量修改分类(已选)
+                    批量改昵称(已选)
+                    批量改用户名(已选)
+                    批量改Bio(已选)
+                
+
+                @if (selectedAccounts.Count > 0)
+                {
+                    已选 @selectedAccounts.Count
+                }
+                else
+                {
+                    共 @importedAccounts.Count 个账号
+                }
+            
+
+            
+                
+                    手机号
+                    用户ID
+                    用户名
+                    分类
+                
+                
+                    @context.DisplayPhone
+                    @context.UserId
+                    @(context.Username ?? "-")
+                    
+                        @if (context.Category != null)
+                        {
+                            
+                                @context.Category.Name
+                            
+                        }
+                        else
+                        {
+                            未分类
+                        }
+                    
+                
+            
+        
+    
+}
+
 @code {
     private List selectedFiles = new();
     private bool importing = false;
@@ -181,6 +269,32 @@
 
     private List importResults = new();
 
+    private bool actionLoading = false;
+    private bool Busy => importing || importingZip || importingString || actionLoading;
+
+    private readonly HashSet importedPhones = new(StringComparer.Ordinal);
+    private List importedAccounts = new();
+    private HashSet selectedAccounts = new();
+    private List categories = new();
+    private readonly Dictionary telegramStatus = new();
+    private readonly CancellationTokenSource _pageCts = new();
+
+    public void Dispose()
+    {
+        try
+        {
+            _pageCts.Cancel();
+        }
+        catch
+        {
+            // ignore
+        }
+        finally
+        {
+            _pageCts.Dispose();
+        }
+    }
+
     private bool TryGetTelegramApi(out int apiId, out string apiHash)
     {
         apiHash = Configuration["Telegram:ApiHash"] ?? "";
@@ -215,6 +329,7 @@
             var twoFactorPwd = string.IsNullOrWhiteSpace(zipTwoFactorPassword) ? null : zipTwoFactorPassword.Trim();
             var results = await AccountImportService.ImportFromZipAsync(zipFile, twoFactorPassword: twoFactorPwd);
             importResults.AddRange(results);
+            await MergeImportedFromResultsAsync(results);
 
             var successCount = results.Count(r => r.Success);
             var failureCount = results.Count - successCount;
@@ -257,6 +372,7 @@
             );
 
             importResults.AddRange(results);
+            await MergeImportedFromResultsAsync(results);
 
             var successCount = results.Count(r => r.Success);
             var failureCount = results.Count - successCount;
@@ -303,6 +419,7 @@
             );
 
             importResults.Add(result);
+            await MergeImportedFromResultsAsync(new[] { result });
 
             if (result.Success)
             {
@@ -323,4 +440,492 @@
             importingString = false;
         }
     }
+
+    private async Task MergeImportedFromResultsAsync(IEnumerable results)
+    {
+        var changed = false;
+        foreach (var r in results)
+        {
+            if (!r.Success)
+                continue;
+            if (string.IsNullOrWhiteSpace(r.Phone))
+                continue;
+
+            var digits = PhoneNumberFormatter.NormalizeToDigits(r.Phone);
+            if (string.IsNullOrWhiteSpace(digits))
+                continue;
+
+            if (importedPhones.Add(digits))
+                changed = true;
+        }
+
+        if (changed)
+            await ReloadImportedAccountsAsync();
+    }
+
+    private async Task ReloadImportedAccountsAsync()
+    {
+        var list = new List();
+        foreach (var phone in importedPhones)
+        {
+            var a = await AccountManagement.GetAccountByPhoneAsync(phone);
+            if (a != null)
+                list.Add(a);
+        }
+
+        importedAccounts = list
+            .Where(a => a.Id > 0)
+            .GroupBy(a => a.Id)
+            .Select(g => g.First())
+            .OrderByDescending(a => a.Id)
+            .ToList();
+
+        selectedAccounts.Clear();
+        StateHasChanged();
+    }
+
+    private Task ClearImportedAccounts()
+    {
+        importedPhones.Clear();
+        importedAccounts.Clear();
+        selectedAccounts.Clear();
+        telegramStatus.Clear();
+        return Task.CompletedTask;
+    }
+
+    private Task SelectAllImported()
+    {
+        selectedAccounts = importedAccounts.ToHashSet();
+        return Task.CompletedTask;
+    }
+
+    private Task InvertSelection()
+    {
+        var targets = importedAccounts.ToList();
+        foreach (var a in targets)
+        {
+            if (selectedAccounts.Contains(a))
+                selectedAccounts.Remove(a);
+            else
+                selectedAccounts.Add(a);
+        }
+        return Task.CompletedTask;
+    }
+
+    private Task ClearSelection()
+    {
+        selectedAccounts.Clear();
+        return Task.CompletedTask;
+    }
+
+    private SelectionToggleMode _selectionToggleMode = SelectionToggleMode.SelectAll;
+    private string SelectionToggleText => _selectionToggleMode switch
+    {
+        SelectionToggleMode.SelectAll => "全选本页",
+        SelectionToggleMode.Invert => "反选本页",
+        SelectionToggleMode.Clear => "清空选择",
+        _ => "全选本页"
+    };
+
+    private string SelectionToggleIcon => _selectionToggleMode switch
+    {
+        SelectionToggleMode.SelectAll => Icons.Material.Filled.SelectAll,
+        SelectionToggleMode.Invert => Icons.Material.Filled.Flip,
+        SelectionToggleMode.Clear => Icons.Material.Filled.Clear,
+        _ => Icons.Material.Filled.SelectAll
+    };
+
+    private async Task CycleSelectionToggle()
+    {
+        switch (_selectionToggleMode)
+        {
+            case SelectionToggleMode.SelectAll:
+                await SelectAllImported();
+                _selectionToggleMode = SelectionToggleMode.Invert;
+                break;
+            case SelectionToggleMode.Invert:
+                await InvertSelection();
+                _selectionToggleMode = SelectionToggleMode.Clear;
+                break;
+            case SelectionToggleMode.Clear:
+                await ClearSelection();
+                _selectionToggleMode = SelectionToggleMode.SelectAll;
+                break;
+            default:
+                _selectionToggleMode = SelectionToggleMode.SelectAll;
+                break;
+        }
+    }
+
+    private enum SelectionToggleMode
+    {
+        SelectAll = 0,
+        Invert = 1,
+        Clear = 2
+    }
+
+    private async Task LoadCategories()
+    {
+        categories = (await CategoryManagement.GetAllCategoriesAsync()).ToList();
+    }
+
+    private async Task OpenBatchChangeTwoFactorPassword()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
+        DialogService.Show("批量修改二级密码", parameters, options);
+        await Task.CompletedTask;
+    }
+
+    private async Task OpenBatchChangeTwoFactorRecoveryEmail()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
+        DialogService.Show("批量换绑邮箱(找回+登录)", parameters, options);
+        await Task.CompletedTask;
+    }
+
+    private async Task OpenBatchSetAccountCategory()
+    {
+        if (Busy)
+            return;
+
+        var targets = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (targets.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        if (categories.Count == 0)
+            await LoadCategories();
+
+        var parameters = new DialogParameters
+        {
+            ["Categories"] = categories,
+            ["SelectedCount"] = targets.Count
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true };
+        var dialog = DialogService.Show("批量修改分类", parameters, options)!;
+        var result = await dialog.Result;
+        if (result is not { Canceled: false })
+            return;
+
+        var categoryIdRaw = result.Data is int x ? x : 0;
+        int? categoryId = categoryIdRaw <= 0 ? null : categoryIdRaw;
+
+        actionLoading = true;
+        try
+        {
+            foreach (var id in targets)
+                await AccountManagement.UpdateAccountCategoryAsync(id, categoryId);
+
+            await ReloadImportedAccountsAsync();
+            Snackbar.Add($"分类已更新:{targets.Count} 个账号", Severity.Success);
+        }
+        catch (Exception ex)
+        {
+            Snackbar.Add($"修改分类失败:{ex.Message}", Severity.Error);
+        }
+        finally
+        {
+            actionLoading = false;
+        }
+    }
+
+    private async Task OpenBatchChangeNickname()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
+        var dialog = DialogService.Show("批量修改昵称", parameters, options)!;
+        var result = await dialog.Result;
+        if (result is { Canceled: false })
+            await ReloadImportedAccountsAsync();
+    }
+
+    private async Task OpenBatchChangeUsername()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
+        var dialog = DialogService.Show("批量修改用户名", parameters, options)!;
+        var result = await dialog.Result;
+        if (result is { Canceled: false })
+            await ReloadImportedAccountsAsync();
+    }
+
+    private async Task OpenBatchChangeBio()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
+        var dialog = DialogService.Show("批量修改 Bio", parameters, options)!;
+        var result = await dialog.Result;
+        if (result is { Canceled: false })
+            await ReloadImportedAccountsAsync();
+    }
+
+    private async Task OpenBatchJoinSubscribe()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids,
+            ["Operation"] = "join"
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
+        DialogService.Show("批量加群/订阅", parameters, options);
+        await Task.CompletedTask;
+    }
+
+    private async Task OpenBatchLeaveUnsubscribe()
+    {
+        if (Busy)
+            return;
+
+        var ids = selectedAccounts.Select(a => a.Id).Distinct().ToList();
+        if (ids.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        var parameters = new DialogParameters
+        {
+            ["AccountIds"] = ids,
+            ["Operation"] = "leave"
+        };
+        var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true };
+        DialogService.Show("批量退群/退订", parameters, options);
+        await Task.CompletedTask;
+    }
+
+    private async Task BatchRefreshTelegramStatus()
+    {
+        var targets = selectedAccounts
+            .Where(a => a.Id > 0)
+            .GroupBy(a => a.Id)
+            .Select(g => g.First())
+            .ToList();
+
+        if (targets.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Warning);
+            return;
+        }
+
+        actionLoading = true;
+        try
+        {
+            bool? probeResult = await DialogService.ShowMessageBox(
+                "刷新已选 Telegram 状态",
+                $"将刷新已选 {targets.Count} 个账号的 Telegram 状态。\n\n是否进行深度探测(将对每个账号创建并删除一个测试频道,用于判断【创建频道接口是否被冻结】)?",
+                yesText: "深度探测",
+                cancelText: "普通刷新");
+
+            var probe = probeResult == true;
+
+            // 批量风控检查:深度探测是敏感操作
+            if (probe && targets.Count > 0)
+            {
+                var batchCheck = RiskService.CheckBatchAccounts(targets);
+                if (batchCheck.HasRiskyAccounts)
+                {
+                    var parameters = new DialogParameters
+                    {
+                        ["Message"] = $"检测到 {batchCheck.RiskyCount} 个风险账号",
+                        ["DetailedMessage"] = batchCheck.GetRiskySummary(),
+                        ["ShowRecommendations"] = true,
+                        ["ShowExcludeOption"] = true,
+                        ["RiskyAccounts"] = batchCheck.RiskyAccounts.ToList()
+                    };
+                    var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large };
+                    var dialog = DialogService.Show("批量操作风控警告", parameters, options);
+                    var dialogResult = await dialog.Result;
+
+                    if (dialogResult.Canceled)
+                    {
+                        actionLoading = false;
+                        return;
+                    }
+
+                    if (dialogResult.Data is TelegramPanel.Core.Services.RiskWarningAction action
+                        && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky)
+                    {
+                        targets = batchCheck.SafeAccounts.ToList();
+                        if (targets.Count == 0)
+                        {
+                            Snackbar.Add("排除风险账号后无剩余账号", Severity.Info);
+                            actionLoading = false;
+                            return;
+                        }
+                    }
+                }
+            }
+
+            foreach (var a in targets)
+            {
+                try
+                {
+                    var result = await AccountTelegramTools.RefreshAccountStatusAsync(a.Id, probeCreateChannel: probe, cancellationToken: _pageCts.Token);
+                    telegramStatus[a.Id] = result;
+                }
+                catch (Exception ex)
+                {
+                    telegramStatus[a.Id] = new TelegramAccountStatusResult(
+                        Ok: false,
+                        Summary: "刷新失败",
+                        Details: ex.Message,
+                        CheckedAtUtc: DateTime.UtcNow);
+                }
+
+                StateHasChanged();
+            }
+
+            await ReloadImportedAccountsAsync();
+            Snackbar.Add($"批量刷新完成:{targets.Count} 个账号", Severity.Success);
+        }
+        catch (Exception ex)
+        {
+            Snackbar.Add($"批量刷新失败:{ex.Message}", Severity.Error);
+        }
+        finally
+        {
+            actionLoading = false;
+        }
+    }
+
+    private async Task BatchKickAllOtherDevices()
+    {
+        if (actionLoading)
+            return;
+
+        var targets = selectedAccounts.ToList();
+        if (targets.Count == 0)
+        {
+            Snackbar.Add("请先选择账号", Severity.Info);
+            return;
+        }
+
+        bool? result = await DialogService.ShowMessageBox(
+            "确认踢出",
+            $"将对 {targets.Count} 个账号执行【踢出所有其他设备】(会保留面板当前会话)。是否继续?",
+            yesText: "继续", cancelText: "取消");
+
+        if (result != true)
+            return;
+
+        actionLoading = true;
+        try
+        {
+            var success = 0;
+            var failures = new List();
+
+            foreach (var a in targets)
+            {
+                try
+                {
+                    var ok = await AccountTelegramTools.KickAllOtherAuthorizationsAsync(a.Id);
+                    if (ok) success++;
+                    else failures.Add($"{a.DisplayPhone}:返回 false");
+                }
+                catch (Exception ex)
+                {
+                    failures.Add($"{a.DisplayPhone}:{ex.Message}");
+                }
+            }
+
+            selectedAccounts.Clear();
+
+            if (failures.Count == 0)
+            {
+                Snackbar.Add($"踢出完成:成功 {success}/{targets.Count}", Severity.Success);
+                return;
+            }
+
+            Snackbar.Add($"踢出完成:成功 {success}/{targets.Count},失败 {failures.Count}", Severity.Warning);
+
+            var details = string.Join(Environment.NewLine, failures.Take(80));
+            if (failures.Count > 80)
+                details += Environment.NewLine + $"... 仅展示前 80 条(共 {failures.Count} 条)";
+
+            await DialogService.ShowMessageBox("失败明细(前 80 条)", details, "关闭");
+        }
+        finally
+        {
+            actionLoading = false;
+        }
+    }
 }
diff --git a/src/TelegramPanel.Web/Components/Pages/Bots.razor b/src/TelegramPanel.Web/Components/Pages/Bots.razor
index 85ab4b5..fdf2550 100644
--- a/src/TelegramPanel.Web/Components/Pages/Bots.razor
+++ b/src/TelegramPanel.Web/Components/Pages/Bots.razor
@@ -275,7 +275,8 @@
 
         try
         {
-            var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{Uri.EscapeDataString(token.Trim())}";
+            var pathToken = WebhookTokenHelper.ToWebhookPathToken(token.Trim());
+            var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}";
             await BotApi.SetWebhookAsync(
                 token: token.Trim(),
                 url: webhookUrl,
diff --git a/src/TelegramPanel.Web/Components/Pages/Settings.razor b/src/TelegramPanel.Web/Components/Pages/Settings.razor
index ddfe4e7..2aa8400 100644
--- a/src/TelegramPanel.Web/Components/Pages/Settings.razor
+++ b/src/TelegramPanel.Web/Components/Pages/Settings.razor
@@ -209,7 +209,7 @@
                     启用日志输出
                 
                 
-                    说明:关闭后会禁用 Serilog 输出(控制台/文件)。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。
+                    说明:关闭后仅输出 Error/Fatal(控制台),并停止写入日志文件。Docker 仍可能记录 stdout/stderr,建议同时在 docker-compose 配置日志滚动。
                 
 
                  BatchTaskTypes.ExternalApiKick;
+
+    public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken)
+    {
+        var logger = host.Services.GetRequiredService>();
+        var taskManagement = host.Services.GetRequiredService();
+        var botManagement = host.Services.GetRequiredService();
+        var botTelegram = host.Services.GetRequiredService();
+
+        var rawConfig = (host.Config ?? "").Trim();
+        if (rawConfig.Length == 0)
+            throw new InvalidOperationException("任务缺少 Config");
+
+        KickTaskLog? input;
+        try
+        {
+            input = JsonSerializer.Deserialize(rawConfig);
+        }
+        catch (Exception ex)
+        {
+            throw new InvalidOperationException($"任务 Config JSON 无效:{ex.Message}");
+        }
+
+        if (input == null)
+            throw new InvalidOperationException("任务 Config JSON 为空");
+
+        if (input.UserId <= 0)
+            throw new InvalidOperationException("任务 user_id 无效");
+
+        input.ChatIds ??= new List();
+
+        var configuredBotId = input.BotId;
+        var useAllChats = configuredBotId == 0 ? true : input.UseAllChats;
+        var chatIdSet = new HashSet(input.ChatIds.Where(x => x != 0));
+
+        var targets = await ResolveTargetsAsync(botManagement, configuredBotId, useAllChats, chatIdSet, cancellationToken);
+        if (targets.TotalChats == 0)
+            throw new InvalidOperationException("未匹配到任何可操作的频道/群组");
+
+        var results = new List(targets.TotalChats);
+        var completed = 0;
+        var failed = 0;
+
+        using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+        var linkedToken = linkedCts.Token;
+
+        try
+        {
+            foreach (var group in targets.Groups)
+            {
+                linkedToken.ThrowIfCancellationRequested();
+
+                if (!await host.IsStillRunningAsync(linkedToken))
+                {
+                    await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
+                    {
+                        ApiName = input.ApiName ?? "",
+                        BotId = configuredBotId,
+                        UseAllChats = useAllChats,
+                        ChatIds = chatIdSet.OrderBy(x => x).ToList(),
+                        UserId = input.UserId,
+                        PermanentBan = input.PermanentBan,
+                        RequestedAtUtc = input.RequestedAtUtc,
+                        Results = results,
+                        Canceled = true
+                    }));
+                    return;
+                }
+
+                var baseCompleted = completed;
+                var baseFailed = failed;
+                var chatById = group.Chats.ToDictionary(c => c.TelegramId);
+                var recorded = new HashSet();
+
+                try
+                {
+                    _ = await botTelegram.BanChatMemberAsync(
+                        botId: group.Bot.Id,
+                        channelTelegramIds: group.Chats.Select(x => x.TelegramId).ToList(),
+                        userId: input.UserId,
+                        permanentBan: input.PermanentBan,
+                        cancellationToken: linkedToken,
+                        perChatCallback: async (chatId, error, okCount, failCount) =>
+                        {
+                            if (!recorded.Add(chatId))
+                                return;
+
+                            var ok = error == null;
+                            results.Add(new KickResultItem(
+                                ChatId: chatId.ToString(),
+                                Title: chatById.TryGetValue(chatId, out var ch) ? ch.Title : chatId.ToString(),
+                                Success: ok,
+                                Error: error));
+
+                            completed = baseCompleted + okCount;
+                            failed = baseFailed + failCount;
+                            await host.UpdateProgressAsync(completed, failed, linkedToken);
+
+                            if (!await host.IsStillRunningAsync(linkedToken))
+                                linkedCts.Cancel();
+                        });
+                }
+                catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+                {
+                    // 用户主动取消(把任务状态改为非 running)
+                    await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
+                    {
+                        ApiName = input.ApiName ?? "",
+                        BotId = configuredBotId,
+                        UseAllChats = useAllChats,
+                        ChatIds = chatIdSet.OrderBy(x => x).ToList(),
+                        UserId = input.UserId,
+                        PermanentBan = input.PermanentBan,
+                        RequestedAtUtc = input.RequestedAtUtc,
+                        Results = results,
+                        Canceled = true
+                    }));
+                    return;
+                }
+                catch (Exception ex)
+                {
+                    logger.LogWarning(ex, "ExternalApiKick failed on bot {BotId} (chats={Count})", group.Bot.Id, group.Chats.Count);
+                    foreach (var chat in group.Chats)
+                    {
+                        if (!recorded.Add(chat.TelegramId))
+                            continue;
+
+                        results.Add(new KickResultItem(
+                            ChatId: chat.TelegramId.ToString(),
+                            Title: chat.Title,
+                            Success: false,
+                            Error: ex.Message));
+                        failed++;
+                    }
+
+                    await host.UpdateProgressAsync(completed, failed, linkedToken);
+                }
+            }
+        }
+        catch (OperationCanceledException) when (linkedCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
+        {
+            await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
+            {
+                ApiName = input.ApiName ?? "",
+                BotId = configuredBotId,
+                UseAllChats = useAllChats,
+                ChatIds = chatIdSet.OrderBy(x => x).ToList(),
+                UserId = input.UserId,
+                PermanentBan = input.PermanentBan,
+                RequestedAtUtc = input.RequestedAtUtc,
+                Results = results,
+                Canceled = true
+            }));
+            return;
+        }
+        catch (Exception ex)
+        {
+            logger.LogWarning(ex, "ExternalApiKick failed (taskId={TaskId})", host.TaskId);
+            await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
+            {
+                ApiName = input.ApiName ?? "",
+                BotId = configuredBotId,
+                UseAllChats = useAllChats,
+                ChatIds = chatIdSet.OrderBy(x => x).ToList(),
+                UserId = input.UserId,
+                PermanentBan = input.PermanentBan,
+                RequestedAtUtc = input.RequestedAtUtc,
+                Results = results,
+                Error = ex.Message
+            }));
+            throw;
+        }
+
+        await taskManagement.UpdateTaskConfigAsync(host.TaskId, SerializeIndented(new KickTaskLog
+        {
+            ApiName = input.ApiName ?? "",
+            BotId = configuredBotId,
+            UseAllChats = useAllChats,
+            ChatIds = chatIdSet.OrderBy(x => x).ToList(),
+            UserId = input.UserId,
+            PermanentBan = input.PermanentBan,
+            RequestedAtUtc = input.RequestedAtUtc,
+            Results = results
+        }));
+    }
+
+    private static async Task ResolveTargetsAsync(
+        BotManagementService botManagement,
+        int configuredBotId,
+        bool useAllChats,
+        HashSet configuredChatIds,
+        CancellationToken cancellationToken)
+    {
+        var bots = (await botManagement.GetAllBotsAsync())
+            .Where(b => b.IsActive)
+            .OrderBy(b => b.Id)
+            .ToList();
+
+        if (configuredBotId > 0)
+            bots = bots.Where(b => b.Id == configuredBotId).ToList();
+
+        var groups = new List();
+        var totalChats = 0;
+
+        foreach (var bot in bots)
+        {
+            cancellationToken.ThrowIfCancellationRequested();
+
+            var chats = (await botManagement.GetChatsAsync(bot.Id)).ToList();
+            if (!useAllChats && configuredChatIds.Count > 0)
+                chats = chats.Where(c => configuredChatIds.Contains(c.TelegramId)).ToList();
+
+            if (chats.Count == 0)
+                continue;
+
+            groups.Add(new TargetGroup(bot, chats));
+            totalChats += chats.Count;
+        }
+
+        return new TargetResolution(groups, totalChats);
+    }
+
+    private static string SerializeIndented(T value)
+    {
+        return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
+    }
+
+    private sealed record TargetGroup(Bot Bot, List Chats);
+    private sealed record TargetResolution(List Groups, int TotalChats);
+}
+
diff --git a/src/TelegramPanel.Web/ExternalApi/KickApi.cs b/src/TelegramPanel.Web/ExternalApi/KickApi.cs
index c43bf51..18bc037 100644
--- a/src/TelegramPanel.Web/ExternalApi/KickApi.cs
+++ b/src/TelegramPanel.Web/ExternalApi/KickApi.cs
@@ -1,6 +1,5 @@
 using System.Security.Cryptography;
 using System.Text;
-using System.Text.Json.Serialization;
 using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Http;
 using Microsoft.AspNetCore.Routing;
@@ -8,7 +7,6 @@ using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.Logging;
 using System.Text.Json;
 using TelegramPanel.Core.Services;
-using TelegramPanel.Core.Services.Telegram;
 using TelegramPanel.Core.BatchTasks;
 using TelegramPanel.Data.Entities;
 
@@ -27,7 +25,6 @@ public static class KickApi
         HttpContext http,
         IConfiguration configuration,
         BotManagementService botManagement,
-        BotTelegramService botTelegram,
         BatchTaskManagementService taskManagement,
         ILoggerFactory loggerFactory,
         CancellationToken cancellationToken)
@@ -86,10 +83,10 @@ public static class KickApi
                 false,
                 "未配置任何可操作的频道/群组(请先在页面选择机器人与频道/群组)",
                 new KickSummary(0, 0, 0),
-                Array.Empty()));
+                    Array.Empty()));
         }
 
-        // 记录到任务中心(即使外部调用返回了,也能追溯这次操作)
+        // 提交到任务中心,由后台执行(避免反代/CF 524 超时)
         var createdTask = await taskManagement.CreateTaskAsync(new BatchTask
         {
             TaskType = BatchTaskTypes.ExternalApiKick,
@@ -108,120 +105,17 @@ public static class KickApi
             })
         });
 
-        await taskManagement.StartTaskAsync(createdTask.Id);
+        logger.LogInformation("Kick task queued: taskId={TaskId} api={ApiName} botId={BotId} total={Total} userId={UserId} permanent={Permanent}",
+            createdTask.Id, matched.Name ?? "", configuredBotId, targets.TotalChats, request.UserId, permanentBan);
 
-        var results = new List(targets.TotalChats);
-        var completed = 0;
-        var failedCount = 0;
-
-        try
-        {
-            foreach (var group in targets.Groups)
-            {
-                cancellationToken.ThrowIfCancellationRequested();
-
-                try
-                {
-                    var banResult = await botTelegram.BanChatMemberAsync(
-                        botId: group.Bot.Id,
-                        channelTelegramIds: group.Chats.Select(x => x.TelegramId).ToList(),
-                        userId: request.UserId,
-                        permanentBan: permanentBan,
-                        cancellationToken: cancellationToken);
-
-                    var failures = banResult.Failures;
-                    foreach (var chat in group.Chats)
-                    {
-                        failures.TryGetValue(chat.TelegramId, out var err);
-                        var ok = err == null;
-                        results.Add(new KickResultItem(
-                            ChatId: chat.TelegramId.ToString(),
-                            Title: chat.Title,
-                            Success: ok,
-                            Error: err));
-
-                        if (ok) completed++; else failedCount++;
-                    }
-                }
-                catch (Exception ex)
-                {
-                    logger.LogWarning(ex, "Kick API failed on bot {BotId} (chats={Count})", group.Bot.Id, group.Chats.Count);
-                    foreach (var chat in group.Chats)
-                    {
-                        results.Add(new KickResultItem(
-                            ChatId: chat.TelegramId.ToString(),
-                            Title: chat.Title,
-                            Success: false,
-                            Error: ex.Message));
-                        failedCount++;
-                    }
-                }
-
-                await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount);
-            }
-        }
-        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
-        {
-            await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount == 0 ? 1 : failedCount);
-            await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog
-            {
-                ApiName = matched.Name ?? "",
-                BotId = configuredBotId,
-                UseAllChats = useAllChats,
-                ChatIds = configuredChatSet.OrderBy(x => x).ToList(),
-                UserId = request.UserId,
-                PermanentBan = permanentBan,
-                RequestedAtUtc = DateTime.UtcNow,
-                Results = results,
-                Canceled = true
-            }));
-            await taskManagement.CompleteTaskAsync(createdTask.Id, success: false);
-            throw;
-        }
-        catch (Exception ex)
-        {
-            logger.LogWarning(ex, "Kick API failed (taskId={TaskId})", createdTask.Id);
-            await taskManagement.UpdateTaskProgressAsync(createdTask.Id, completed, failedCount == 0 ? 1 : failedCount);
-            await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog
-            {
-                ApiName = matched.Name ?? "",
-                BotId = configuredBotId,
-                UseAllChats = useAllChats,
-                ChatIds = configuredChatSet.OrderBy(x => x).ToList(),
-                UserId = request.UserId,
-                PermanentBan = permanentBan,
-                RequestedAtUtc = DateTime.UtcNow,
-                Results = results,
-                Error = ex.Message
-            }));
-            await taskManagement.CompleteTaskAsync(createdTask.Id, success: false);
-            throw;
-        }
-
-        var okCount = results.Count(x => x.Success);
-        var total = results.Count;
-        var failedTotal = total - okCount;
-        var actionText = permanentBan ? "Banned" : "Kicked";
-        var message = $"{actionText} user {request.UserId} from {okCount}/{total} chats";
-
-        await taskManagement.UpdateTaskConfigAsync(createdTask.Id, SerializeIndented(new KickTaskLog
-        {
-            ApiName = matched.Name ?? "",
-            BotId = configuredBotId,
-            UseAllChats = useAllChats,
-            ChatIds = configuredChatSet.OrderBy(x => x).ToList(),
-            UserId = request.UserId,
-            PermanentBan = permanentBan,
-            RequestedAtUtc = DateTime.UtcNow,
-            Results = results
-        }));
-        await taskManagement.CompleteTaskAsync(createdTask.Id, success: failedTotal == 0);
-
-        return Results.Ok(new KickResponse(
-            Success: true,
-            Message: message,
-            Summary: new KickSummary(total, okCount, failedTotal),
-            Results: results));
+        return Results.Json(
+            new KickResponse(
+                Success: true,
+                Message: $"已提交后台任务 #{createdTask.Id},请到面板【任务中心】查看进度与结果。",
+                Summary: new KickSummary(targets.TotalChats, 0, 0),
+                Results: Array.Empty(),
+                TaskId: createdTask.Id),
+            statusCode: StatusCodes.Status202Accepted);
     }
 
     private static async Task ResolveTargetsAsync(
@@ -280,41 +174,7 @@ public static class KickApi
         return JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
     }
 
-    public sealed record KickRequest(
-        [property: JsonPropertyName("user_id")] long UserId,
-        [property: JsonPropertyName("permanent_ban")] bool? PermanentBan = null);
-
-    public sealed record KickResponse(
-        [property: JsonPropertyName("success")] bool Success,
-        [property: JsonPropertyName("message")] string Message,
-        [property: JsonPropertyName("summary")] KickSummary Summary,
-        [property: JsonPropertyName("results")] IReadOnlyList Results);
-
-    public sealed record KickSummary(
-        [property: JsonPropertyName("total")] int Total,
-        [property: JsonPropertyName("success")] int Success,
-        [property: JsonPropertyName("failed")] int Failed);
-
-    public sealed record KickResultItem(
-        [property: JsonPropertyName("chat_id")] string ChatId,
-        [property: JsonPropertyName("title")] string Title,
-        [property: JsonPropertyName("success")] bool Success,
-        [property: JsonPropertyName("error")] string? Error);
-
     private sealed record TargetGroup(Bot Bot, List Chats);
     private sealed record TargetResolution(List Groups, int TotalChats);
 
-    private sealed class KickTaskLog
-    {
-        public string ApiName { get; set; } = "";
-        public int BotId { get; set; }
-        public bool UseAllChats { get; set; }
-        public List ChatIds { get; set; } = new();
-        public long UserId { get; set; }
-        public bool PermanentBan { get; set; }
-        public DateTime RequestedAtUtc { get; set; }
-        public bool Canceled { get; set; }
-        public string? Error { get; set; }
-        public List? Results { get; set; }
-    }
 }
diff --git a/src/TelegramPanel.Web/ExternalApi/KickModels.cs b/src/TelegramPanel.Web/ExternalApi/KickModels.cs
new file mode 100644
index 0000000..3bc7681
--- /dev/null
+++ b/src/TelegramPanel.Web/ExternalApi/KickModels.cs
@@ -0,0 +1,62 @@
+using System.Text.Json.Serialization;
+
+namespace TelegramPanel.Web.ExternalApi;
+
+public sealed record KickRequest(
+    [property: JsonPropertyName("user_id")] long UserId,
+    [property: JsonPropertyName("permanent_ban")] bool? PermanentBan = null);
+
+public sealed record KickResponse(
+    [property: JsonPropertyName("success")] bool Success,
+    [property: JsonPropertyName("message")] string Message,
+    [property: JsonPropertyName("summary")] KickSummary Summary,
+    [property: JsonPropertyName("results")] IReadOnlyList Results,
+    [property: JsonPropertyName("task_id")] int? TaskId = null);
+
+public sealed record KickSummary(
+    [property: JsonPropertyName("total")] int Total,
+    [property: JsonPropertyName("success")] int Success,
+    [property: JsonPropertyName("failed")] int Failed);
+
+public sealed record KickResultItem(
+    [property: JsonPropertyName("chat_id")] string ChatId,
+    [property: JsonPropertyName("title")] string Title,
+    [property: JsonPropertyName("success")] bool Success,
+    [property: JsonPropertyName("error")] string? Error);
+
+public sealed class KickTaskLog
+{
+    [JsonPropertyName("api_name")]
+    public string ApiName { get; set; } = "";
+
+    /// 
+    /// 0=all bots
+    /// 
+    [JsonPropertyName("bot_id")]
+    public int BotId { get; set; }
+
+    [JsonPropertyName("use_all_chats")]
+    public bool UseAllChats { get; set; }
+
+    [JsonPropertyName("chat_ids")]
+    public List ChatIds { get; set; } = new();
+
+    [JsonPropertyName("user_id")]
+    public long UserId { get; set; }
+
+    [JsonPropertyName("permanent_ban")]
+    public bool PermanentBan { get; set; }
+
+    [JsonPropertyName("requested_at_utc")]
+    public DateTime RequestedAtUtc { get; set; }
+
+    [JsonPropertyName("canceled")]
+    public bool Canceled { get; set; }
+
+    [JsonPropertyName("error")]
+    public string? Error { get; set; }
+
+    [JsonPropertyName("results")]
+    public List? Results { get; set; }
+}
+
diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs
index 1bcf14a..c52ea2e 100644
--- a/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs
+++ b/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs
@@ -2,12 +2,14 @@ using Microsoft.AspNetCore.Builder;
 using Microsoft.AspNetCore.Routing;
 using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using MudBlazor;
 using TelegramPanel.Modules;
+using TelegramPanel.Web.Components.ModulePages;
 using TelegramPanel.Web.ExternalApi;
 
 namespace TelegramPanel.Web.Modules.BuiltIn;
 
-public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider
+public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider, IModuleUiProvider
 {
     public KickApiModule(string version)
     {
@@ -25,7 +27,7 @@ public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider
 
     public void ConfigureServices(IServiceCollection services, ModuleHostContext context)
     {
-        // built-in:无需额外注入
+        services.AddScoped();
     }
 
     public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context)
@@ -49,4 +51,22 @@ public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider
             Order = 10
         };
     }
+
+    public IEnumerable GetNavItems(ModuleHostContext context)
+    {
+        yield break;
+    }
+
+    public IEnumerable GetPages(ModuleHostContext context)
+    {
+        yield return new ModulePageDefinition
+        {
+            Key = "kick",
+            Title = "踢人/封禁",
+            Icon = Icons.Material.Filled.PersonRemove,
+            Group = "外部 API",
+            Order = 10,
+            ComponentType = typeof(KickApiPage).AssemblyQualifiedName ?? ""
+        };
+    }
 }
diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs
index 19b608e..81187ba 100644
--- a/src/TelegramPanel.Web/Program.cs
+++ b/src/TelegramPanel.Web/Program.cs
@@ -15,6 +15,7 @@ using TelegramPanel.Web.Services;
 using Microsoft.Extensions.Logging;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.Data.Sqlite;
+using System.Diagnostics;
 
 // 诊断:对某个目录下的 *.json/*.session 做一次“可转换/可校验”检查(不写数据库)
 // 用法:dotnet run --project src/TelegramPanel.Web -- --diag-session-dir "D:/path/to/dir"
@@ -254,24 +255,64 @@ static int ReadRetainedFileCountLimit(IConfiguration configuration)
 var retainedFileCountLimit = ReadRetainedFileCountLimit(builder.Configuration);
 var serilogEnabled = builder.Configuration.GetValue("Serilog:Enabled", false);
 
-if (!serilogEnabled)
+var loggerConfig = new LoggerConfiguration()
+    .Enrich.FromLogContext();
+
+if (serilogEnabled)
 {
-    Log.Logger = new LoggerConfiguration()
-        .MinimumLevel.Fatal()
-        .CreateLogger();
+    loggerConfig = loggerConfig
+        .ReadFrom.Configuration(builder.Configuration)
+        // buffered=true:降低磁盘抖动/IO 阻塞导致的请求卡顿风险(尤其是低配 VPS/挂载卷/杀软扫描场景)
+        .WriteTo.File(
+            "logs/telegram-panel-.log",
+            rollingInterval: RollingInterval.Day,
+            retainedFileCountLimit: retainedFileCountLimit,
+            buffered: true,
+            flushToDiskInterval: TimeSpan.FromSeconds(1));
 }
 else
 {
-    Log.Logger = new LoggerConfiguration()
-        .ReadFrom.Configuration(builder.Configuration)
-        .Enrich.FromLogContext()
-        .WriteTo.Console()
-        .WriteTo.File("logs/telegram-panel-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: retainedFileCountLimit)
-        .CreateLogger();
+    // 关闭“详细日志输出”时,仍保留 Error/Fatal:避免进程异常退出却完全看不到日志。
+    loggerConfig = loggerConfig.MinimumLevel.Error();
 }
 
+loggerConfig = loggerConfig.WriteTo.Console();
+Log.Logger = loggerConfig.CreateLogger();
+
 builder.Host.UseSerilog();
 
+// 兜底:即使日志配置被关闭/过滤,也尽量把致命异常打到 stderr
+AppDomain.CurrentDomain.UnhandledException += (_, e) =>
+{
+    try
+    {
+        if (e.ExceptionObject is Exception ex)
+            Log.Fatal(ex, "Unhandled exception (IsTerminating={IsTerminating})", e.IsTerminating);
+        else
+            Log.Fatal("Unhandled exception: {ExceptionObject} (IsTerminating={IsTerminating})", e.ExceptionObject, e.IsTerminating);
+    }
+    catch
+    {
+        // ignore
+    }
+};
+
+TaskScheduler.UnobservedTaskException += (_, e) =>
+{
+    try
+    {
+        Log.Error(e.Exception, "Unobserved task exception");
+    }
+    catch
+    {
+        // ignore
+    }
+    finally
+    {
+        e.SetObserved();
+    }
+};
+
 // Add services to the container.
 builder.Services.AddRazorComponents()
     .AddInteractiveServerComponents();
@@ -670,7 +711,8 @@ app.UseStaticFiles();
 app.UseAntiforgery();
 
 // Serilog 请求日志
-app.UseSerilogRequestLogging();
+if (serilogEnabled)
+    app.UseSerilogRequestLogging();
 
 app.UseAuthentication();
 app.UseAuthorization();
@@ -969,11 +1011,6 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
         }
     }
 
-    // 从路径中的 secretToken 提取 bot token
-    // 我们使用 SHA256(bot_token) 作为 URL 中的 secret,避免暴露真实 token
-    // 但更简单的方式是直接用 bot token(Telegram 官方也是这么做的)
-    // 这里我们假设 secretToken 就是 bot token 的一部分(安全的做法是用 hash)
-
     // 读取请求体
     using var reader = new System.IO.StreamReader(http.Request.Body);
     var body = await reader.ReadToEndAsync(cancellationToken);
@@ -989,10 +1026,16 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
         using var doc = System.Text.Json.JsonDocument.Parse(body);
         var update = doc.RootElement;
 
-        // 尝试从数据库查找匹配的 bot token
-        // 由于 URL 中不应该暴露完整 token,我们需要一个映射机制
-        // 这里简化处理:直接用 secretToken 作为查找键
-        var success = await updateHub.InjectWebhookUpdateAsync(secretToken, update.Clone(), cancellationToken);
+        var botToken = await updateHub.ResolveBotTokenFromWebhookPathAsync(secretToken, cancellationToken);
+        if (string.IsNullOrWhiteSpace(botToken))
+        {
+            logger.LogWarning("Webhook update rejected: unknown or inactive bot");
+            return Results.NotFound();
+        }
+
+        var sw = Stopwatch.StartNew();
+        var success = await updateHub.InjectWebhookUpdateAsync(botToken, update.Clone(), cancellationToken);
+        sw.Stop();
 
         if (!success)
         {
@@ -1000,8 +1043,11 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
             return Results.NotFound();
         }
 
-        logger.LogDebug("Webhook update processed: update_id={UpdateId}",
-            update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0);
+        var updateId = update.TryGetProperty("update_id", out var uid) ? uid.GetInt64() : 0;
+        if (sw.Elapsed > TimeSpan.FromSeconds(2))
+            logger.LogWarning("Webhook update processed slowly: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds);
+        else
+            logger.LogDebug("Webhook update processed: update_id={UpdateId} elapsed_ms={ElapsedMs}", updateId, (long)sw.Elapsed.TotalMilliseconds);
 
         return Results.Ok();
     }
@@ -1017,4 +1063,11 @@ app.MapPost("/api/bot/webhook/{secretToken}", async (
 
 Log.Information("Telegram Panel started");
 
-app.Run();
+try
+{
+    app.Run();
+}
+finally
+{
+    Log.CloseAndFlush();
+}
diff --git a/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs b/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs
index 382b6e4..50f0708 100644
--- a/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs
+++ b/src/TelegramPanel.Web/Services/WebhookRegistrationService.cs
@@ -98,9 +98,10 @@ public sealed class WebhookRegistrationService : BackgroundService
             {
                 var token = bot.Token!.Trim();
 
-                // 构建 Webhook URL:baseUrl + /api/bot/webhook/{token}
-                // 注意:这里用 bot token 作为 URL 中的 secretToken,Telegram 会在请求头中发送配置的 secretToken
-                var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{Uri.EscapeDataString(token)}";
+                // 构建 Webhook URL:baseUrl + /api/bot/webhook/{SHA256(token)}
+                // 说明:避免在反向代理/access log 中泄露真实 bot token。
+                var pathToken = WebhookTokenHelper.ToWebhookPathToken(token);
+                var webhookUrl = $"{baseUrl.TrimEnd('/')}/api/bot/webhook/{pathToken}";
 
                 await botApi.SetWebhookAsync(
                     token: token,