From 58c9de9017166188abc825c87138b7d3a77aed43 Mon Sep 17 00:00:00 2001 From: meoacgx Date: Sun, 21 Dec 2025 09:50:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A8=A1=E5=9D=97=E7=B3=BB=E7=BB=9F=EF=BC=9A?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E4=BB=BB=E5=8A=A1/API/UI=20=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E4=B8=8E=E7=A4=BA=E4=BE=8B=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加模块加载隔离与依赖解析,避免类型身份不一致 - 模块管理:安装/启用/停用/删除与回滚 - 新建任务:支持模块自定义编辑器草稿回传(修复提交提示‘请先完善任务配置’) - 新增示例模块:用户任务‘批量订阅/加群’与打包脚本 --- .gitignore | 3 + README.md | 4 +- docs/modules.md | 11 + .../Dialogs/CreateBatchTaskDialog.razor | 274 +++--------------- .../Components/Pages/Accounts.razor | 18 -- .../Components/Pages/ModulePageHost.razor | 16 +- .../Components/Pages/Modules.razor | 75 ++++- .../Components/Pages/Tasks.razor | 2 +- .../Modules/BuiltIn/TaskCatalogModule.cs | 11 - .../Modules/ModuleInstallerService.cs | 194 ++++++++++++- .../Modules/ModuleLoadContext.cs | 22 +- .../Services/BatchTaskBackgroundService.cs | 49 ---- tools/package-module.ps1 | 68 +++++ 13 files changed, 415 insertions(+), 332 deletions(-) create mode 100644 tools/package-module.ps1 diff --git a/.gitignore b/.gitignore index a163bb6..3292848 100644 --- a/.gitignore +++ b/.gitignore @@ -63,3 +63,6 @@ todolist # Docker persistent data docker-data/ + +# Local build artifacts +artifacts/ diff --git a/README.md b/README.md index ec3d413..62a3300 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 📥 **多账号批量导入/登录**:支持 Session/压缩包导入;支持手机号验证码登录与 2FA 密码 - 🔁 **账号维度一键切换操作**:选择不同账号创建频道/群组、查看与管理账号创建的数据 - 👥 **批量运营能力**:批量邀请成员/机器人、批量设置管理员、导出链接等高频操作 -- 🧩 **模块化扩展能力**:任务 / API / UI 可安装扩展(内置含:批量订阅/加群、踢人/封禁等示例模块) +- 🧩 **模块化扩展能力**:任务 / API / UI 可安装扩展(内置示例:外部 API 踢人/封禁;更多示例见 `docs/modules.md`) - 🔐 **二级密码(2FA)与找回邮箱**:支持单个/批量修改二级密码;支持绑定/换绑 2FA 找回邮箱(验证码确认) - 🧯 **忘记二级密码可申请重置**:支持单个或批量向 Telegram 提交“忘记密码重置”申请(通常等待 7 天后可重新设置) - 🪪 **账号资料管理**:支持单账号编辑昵称/Bio/用户名/头像;支持批量修改昵称(自动追加手机号后 4 位便于区分)与批量修改 Bio @@ -196,7 +196,7 @@ dotnet run --project src/TelegramPanel.Web 内置扩展只是示例(可按需启用/停用,也可自行开发/上传模块): - **外部 API:踢人/封禁**:`POST /api/kick`(配置入口:面板左侧菜单「API 管理」) -- **用户任务:批量订阅/加群**:任务中心新建「用户任务 → 批量订阅/加群」 +- **示例可导入模块:批量订阅/加群**:源码 `src/TelegramPanel.Modules.UserJoinSubscribe/`,打包 `powershell tools/package-module.ps1`,上传 `artifacts/modules/sample.user-join-subscribe-*.tpm` 更多说明见:`docs/modules.md`(模块目录结构、manifest、任务/API/UI 扩展点)。 diff --git a/docs/modules.md b/docs/modules.md index d4b02a3..73870e0 100644 --- a/docs/modules.md +++ b/docs/modules.md @@ -70,8 +70,19 @@ modules/ - `manifest.json` - `lib/.dll`(入口程序集) +> 小提示:如果你是“右键压缩整个文件夹”,压缩包里通常会多一层根目录(`/manifest.json`)。宿主会尝试自动识别并提升这一层;但更推荐直接把 `manifest.json` 和 `lib/` 放在压缩包根目录。 + 安装流程会先解压到 `staging/` 并做基础校验,然后移动到 `installed///`,并将原包存档到 `packages//.tpm` 便于留档与回滚。 +## 示例:批量订阅/加群模块(可导入) + +仓库内提供了一个可导入的示例模块源码:`src/TelegramPanel.Modules.UserJoinSubscribe/`(任务类型:`user_join_subscribe`)。 + +在没有本机 `dotnet` 的情况下,可用 Docker 一键打包: + +- `powershell tools/package-module.ps1` +- 产物输出:`artifacts/modules/sample.user-join-subscribe-*.tpm` + ## manifest.json(示例) ```json diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor index 887608c..aa7462a 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -1,7 +1,4 @@ @namespace TelegramPanel.Web.Components.Dialogs -@inject AccountManagementService AccountManagement -@inject AccountCategoryManagementService CategoryManagement -@inject TelegramPanel.Core.Services.AccountRiskService RiskService @inject BatchTaskManagementService TaskManagement @inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject ISnackbar Snackbar @@ -9,9 +6,7 @@ @inject NavigationManager Navigation @using System.Text.Json @using Microsoft.AspNetCore.Components -@using TelegramPanel.Core.BatchTasks @using TelegramPanel.Data.Entities -@using TelegramPanel.Web.Modules @@ -44,64 +39,7 @@ @CurrentOption.Description } - @if (selectedTaskType == BatchTaskTypes.UserJoinSubscribe) - { - - - - 批量订阅频道/加群(用户任务):支持输入多个链接/用户名,换行分隔。支持:https://t.me/xxx、t.me/+hash、@@username、username、tg://join?invite=hash。 - - - - - - - - 选择执行账号:@selectedAccounts.Count / @accounts.Count - - 全选当前筛选 - 清空 - - - - - - 全部分类 - 未分类 - @foreach (var c in categories) - { - @c.Name - } - - - - - - - - - - 手机号 - 昵称 - 用户名 - 分类 - - - @context.Phone - @(string.IsNullOrWhiteSpace(context.Nickname) ? "-" : context.Nickname) - @(string.IsNullOrWhiteSpace(context.Username) ? "-" : "@" + context.Username) - @GetAccountCategoryName(context.CategoryId) - - - 暂无账号 - - - } - else if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) + @if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) { 该任务建议使用独立页面创建:@CurrentOption.CreateRoute @@ -170,14 +108,23 @@ EnsureTaskTypeValid(); } } - private string selectedTaskType = ""; + private string _selectedTaskType = ""; + private string selectedTaskType + { + get => _selectedTaskType; + set + { + value = (value ?? "").Trim(); + if (string.Equals(_selectedTaskType, value, StringComparison.OrdinalIgnoreCase)) + return; + + _selectedTaskType = value; + ResolveEditorComponent(); + } + } private bool creating; - // join/subscribe config - private string linksText = ""; - private int delayMs = 2000; - // generic task config private string genericConfigJson = ""; private int genericTotal = 0; @@ -187,13 +134,6 @@ private Type? editorComponentType; private Dictionary editorParameters = new(); - private bool loadingAccounts; - private List accounts = new(); - private List categories = new(); - private string accountSearch = ""; - private int filterAccountCategoryId = 0; - private HashSet selectedAccounts = new(); - private IEnumerable AvailableCategories => Contributions.Tasks .Select(x => (x.Definition.Category ?? "").Trim()) @@ -216,63 +156,13 @@ ? reg.Definition : null; - private IEnumerable FilteredAccounts => - accounts.Where(a => - { - if (filterAccountCategoryId > 0 && a.CategoryId != filterAccountCategoryId) - return false; - if (filterAccountCategoryId == -1 && a.CategoryId != null) - return false; - - if (string.IsNullOrWhiteSpace(accountSearch)) - return true; - var q = accountSearch.Trim(); - return a.Phone.Contains(q, StringComparison.OrdinalIgnoreCase) - || (a.Nickname?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false) - || (a.Username?.Contains(q, StringComparison.OrdinalIgnoreCase) ?? false); - }); - protected override async Task OnInitializedAsync() { if (string.IsNullOrWhiteSpace(selectedCategory)) selectedCategory = AvailableCategories.FirstOrDefault() ?? "user"; EnsureTaskTypeValid(); - ResolveEditorComponent(); - await LoadCategories(); - await LoadAccounts(); - } - - private async Task LoadCategories() - { - try - { - var list = await CategoryManagement.GetAllCategoriesAsync(); - categories = list.OrderBy(c => c.Id).ToList(); - } - catch - { - categories = new List(); - } - } - - private async Task LoadAccounts() - { - loadingAccounts = true; - try - { - var list = await AccountManagement.GetActiveAccountsAsync(); - accounts = list.OrderBy(a => a.Id).ToList(); - selectedAccounts = selectedAccounts.Where(a => accounts.Any(x => x.Id == a.Id)).ToHashSet(); - } - catch (Exception ex) - { - Snackbar.Add($"加载账号失败:{ex.Message}", Severity.Error); - } - finally - { - loadingAccounts = false; - } + await Task.CompletedTask; } private void EnsureTaskTypeValid() @@ -281,10 +171,8 @@ { selectedTaskType = AvailableOptions.FirstOrDefault()?.TaskType ?? Contributions.Tasks.FirstOrDefault()?.Definition.TaskType - ?? BatchTaskTypes.UserJoinSubscribe; + ?? ""; } - - ResolveEditorComponent(); } private static string GetCategoryDisplayName(string category) => category switch @@ -312,9 +200,10 @@ return; var typeName = opt.EditorComponentType.Trim(); + var moduleTypeName = NormalizeModuleTypeName(typeName); editorComponentType = Type.GetType(typeName, throwOnError: false, ignoreCase: false) - ?? reg.Module.Instance.GetType().Assembly.GetType(typeName, throwOnError: false, ignoreCase: false); + ?? reg.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); if (editorComponentType == null) return; @@ -331,14 +220,18 @@ editorDraft = draft; } - private void SelectAll() => selectedAccounts = FilteredAccounts.ToHashSet(); - private void ClearSelection() => selectedAccounts = new HashSet(); - - private string GetAccountCategoryName(int? categoryId) + private static string NormalizeModuleTypeName(string typeName) { - if (categoryId is not > 0) - return "未分类"; - return categories.FirstOrDefault(c => c.Id == categoryId)?.Name ?? $"#{categoryId}"; + typeName = (typeName ?? "").Trim(); + if (typeName.Length == 0) + return typeName; + + // 兼容 AssemblyQualifiedName:Assembly.GetType 只接受纯 FullName + var comma = typeName.IndexOf(',', StringComparison.Ordinal); + if (comma > 0) + return typeName.Substring(0, comma).Trim(); + + return typeName; } private async Task Create() @@ -346,8 +239,6 @@ if (creating) return; - EnsureTaskTypeValid(); - if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) { Snackbar.Add("该任务建议使用独立页面创建,请点击“前往创建”。", Severity.Info); @@ -362,84 +253,24 @@ return; } - creating = true; - try + var config = string.IsNullOrWhiteSpace(editorDraft.Config) ? null : editorDraft.Config.Trim(); + if (!string.IsNullOrWhiteSpace(config)) { - var task = new BatchTask + try { - TaskType = selectedTaskType, - Total = editorDraft.Total, - Completed = 0, - Failed = 0, - Config = editorDraft.Config - }; - - var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); - MudDialog.Close(DialogResult.Ok(created.Id)); - } - catch (Exception ex) - { - Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); - } - finally - { - creating = false; - } - - return; - } - - if (string.Equals(selectedTaskType, BatchTaskTypes.UserJoinSubscribe, StringComparison.OrdinalIgnoreCase)) - { - var links = ParseLines(linksText); - if (links.Count == 0) - { - Snackbar.Add("请至少输入一个频道/群链接", Severity.Warning); - return; - } - - var selected = selectedAccounts.ToList(); - if (selected.Count == 0) - { - Snackbar.Add("请至少选择一个执行账号", Severity.Warning); - return; - } - - // 批量风控检查:加入群/订阅频道是敏感操作 - var batchCheck = RiskService.CheckBatchAccounts(selected); - if (batchCheck.HasRiskyAccounts) - { - var parameters = new DialogParameters + JsonDocument.Parse(config); + } + catch (Exception ex) { - ["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 result = await dialog.Result; - - if (result.Canceled) + Snackbar.Add($"配置 JSON 无效:{ex.Message}", Severity.Warning); return; - - if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action - && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) - { - selected = batchCheck.SafeAccounts.ToList(); - if (selected.Count == 0) - { - Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); - return; - } } } + var total = editorDraft.Total < 0 ? 0 : editorDraft.Total; bool? confirm = await DialogService.ShowMessageBox( "确认提交任务", - $"将对 {selected.Count} 个账号执行 {links.Count} 个链接的订阅/加群操作,总计 {selected.Count * links.Count} 次。是否继续?", + $"将提交任务类型:{selectedTaskType},总数:{total}。是否继续?", yesText: "继续", cancelText: "取消"); if (confirm != true) @@ -448,23 +279,17 @@ creating = true; try { - var ids = selected.Select(a => a.Id).Distinct().ToList(); var task = new BatchTask { - TaskType = BatchTaskTypes.UserJoinSubscribe, - Total = ids.Count * links.Count, + TaskType = selectedTaskType, + Total = total, Completed = 0, Failed = 0, - Config = JsonSerializer.Serialize(new - { - AccountIds = ids, - Links = links, - DelayMs = delayMs - }) + Config = config }; var created = await TaskManagement.CreateTaskAsync(task); - Snackbar.Add($"已提交后台任务 #{created.Id}(批量订阅/加群)。请到【任务中心】查看进度。", Severity.Success); + Snackbar.Add($"已提交后台任务 #{created.Id}({selectedTaskType})。请到【任务中心】查看进度。", Severity.Success); MudDialog.Close(DialogResult.Ok(created.Id)); } catch (Exception ex) @@ -533,18 +358,5 @@ Navigation.NavigateTo(url); } - private static List ParseLines(string? text) - { - if (string.IsNullOrWhiteSpace(text)) - return new List(); - - return text - .Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries) - .Select(x => (x ?? string.Empty).Trim()) - .Where(x => !string.IsNullOrWhiteSpace(x)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - } - private void Cancel() => MudDialog.Close(); } diff --git a/src/TelegramPanel.Web/Components/Pages/Accounts.razor b/src/TelegramPanel.Web/Components/Pages/Accounts.razor index 422ae96..303c058 100644 --- a/src/TelegramPanel.Web/Components/Pages/Accounts.razor +++ b/src/TelegramPanel.Web/Components/Pages/Accounts.razor @@ -125,8 +125,6 @@ OnClick="@(() => ShowDetails(context))" Title="查看详情" /> - ("进群/订阅", parameters, options)!; - var result = await dialog.Result; - if (result is { Canceled: false }) - await LoadAccounts(); - } - private async Task OpenBatchChangeTwoFactorPassword() { if (loading) diff --git a/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor b/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor index d71ff2b..6edfc01 100644 --- a/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor +++ b/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor @@ -58,9 +58,10 @@ else return; } + var moduleTypeName = NormalizeModuleTypeName(typeName); componentType = Type.GetType(typeName, throwOnError: false, ignoreCase: false) - ?? page.Module.Instance.GetType().Assembly.GetType(typeName, throwOnError: false, ignoreCase: false); + ?? page.Module.Instance.GetType().Assembly.GetType(moduleTypeName, throwOnError: false, ignoreCase: false); if (componentType == null) { @@ -74,4 +75,17 @@ else ["PageKey"] = pageKey }; } + + private static string NormalizeModuleTypeName(string typeName) + { + typeName = (typeName ?? "").Trim(); + if (typeName.Length == 0) + return typeName; + + var comma = typeName.IndexOf(',', StringComparison.Ordinal); + if (comma > 0) + return typeName.Substring(0, comma).Trim(); + + return typeName; + } } diff --git a/src/TelegramPanel.Web/Components/Pages/Modules.razor b/src/TelegramPanel.Web/Components/Pages/Modules.razor index 39c7b67..7b5825b 100644 --- a/src/TelegramPanel.Web/Components/Pages/Modules.razor +++ b/src/TelegramPanel.Web/Components/Pages/Modules.razor @@ -117,6 +117,13 @@ @if (!context.BuiltIn) { + @if (context.InstalledVersions.Count > 1) + { + + 清理旧版本 + + } + 删除 @@ -235,15 +242,73 @@ if (ok != true) return; - var r = await ModuleInstaller.RemoveModuleAsync(id); - if (!r.Success) + try { - Snackbar.Add($"删除失败:{r.Message}", Severity.Error); + var r = await ModuleInstaller.RemoveModuleAsync(id); + if (!r.Success) + { + Snackbar.Add(r.Message, Severity.Error); + return; + } + + Snackbar.Add("已删除(重启后生效)", Severity.Success); + await Reload(); + } + catch (Exception ex) + { + Snackbar.Add($"删除异常:{ex.Message}", Severity.Error); + } + } + + private async Task PruneOldVersions(ModuleOverview m) + { + if (m.BuiltIn) + return; + + var active = (m.ActiveVersion ?? "").Trim(); + var lastGood = (m.LastGoodVersion ?? "").Trim(); + var keep = new HashSet(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(active)) keep.Add(active); + if (!string.IsNullOrWhiteSpace(lastGood)) keep.Add(lastGood); + + var toRemove = m.InstalledVersions + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.Ordinal) + .Where(v => !keep.Contains(v)) + .ToList(); + + if (toRemove.Count == 0) + { + Snackbar.Add("没有可清理的旧版本", Severity.Info); return; } - Snackbar.Add("已删除(重启后生效)", Severity.Success); - await Reload(); + bool? ok = await DialogService.ShowMessageBox( + "清理旧版本", + $"将删除模块「{m.Id}」的旧版本:{string.Join(", ", toRemove)}。\n" + + $"保留版本:{string.Join(", ", keep)}。\n" + + "删除会移动到回收站目录(可手动找回),是否继续?", + yesText: "继续", cancelText: "取消"); + + if (ok != true) + return; + + try + { + var r = await ModuleInstaller.PruneOldVersionsAsync(m.Id); + if (!r.Success) + { + Snackbar.Add(r.Message, Severity.Error); + return; + } + + Snackbar.Add("已清理旧版本(重启后生效)", Severity.Success); + await Reload(); + } + catch (Exception ex) + { + Snackbar.Add($"清理异常:{ex.Message}", Severity.Error); + } } private async Task ShowDetails(ModuleOverview m) diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index 3ed22f0..ae8d974 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -194,7 +194,7 @@ } // fallback:兼容历史任务 - return taskType == BatchTaskTypes.UserJoinSubscribe ? "user" : "bot"; + return "other"; } private string GetCategoryName(string taskType) => GetCategoryDisplayName(GetCategory(taskType)); diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs index 8e87c64..ba2d2a2 100644 --- a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -54,17 +54,6 @@ public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvide Order = 20 }; - yield return new ModuleTaskDefinition - { - Category = "user", - TaskType = BatchTaskTypes.UserJoinSubscribe, - DisplayName = "批量订阅/加群", - Description = "用户账号批量加入群组/订阅频道(Bot 无法主动加入)。", - Icon = Icons.Material.Filled.GroupAdd, - // 暂时由任务中心内置创建器创建(后续可改为模块自带 editor page) - Order = 30 - }; - yield return new ModuleTaskDefinition { Category = "system", diff --git a/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs b/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs index 2e1c3e4..22c1bbb 100644 --- a/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs +++ b/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs @@ -100,6 +100,12 @@ public sealed class ModuleInstallerService // 2) 读取 manifest var manifestPath = Path.Combine(staging, "manifest.json"); + if (!File.Exists(manifestPath)) + { + // 兼容用户把“文件夹整体压缩”的情况://manifest.json + TryPromoteSingleRootFolder(staging); + } + if (!File.Exists(manifestPath)) return InstallResult.Fail("缺少 manifest.json"); @@ -294,25 +300,132 @@ public sealed class ModuleInstallerService if (item.BuiltIn) return OperationResult.Fail("内置模块不允许删除"); + // 先禁用,避免重启后继续加载(即使后续物理删除失败) item.Enabled = false; await _stateStore.SaveAsync(state); - var moduleDir = Path.Combine(_layout.InstalledDir, id); - if (Directory.Exists(moduleDir)) + try { - var trash = Path.Combine(_layout.TrashDir, $"{id}-{DateTime.UtcNow:yyyyMMddHHmmss}"); - Directory.Move(moduleDir, trash); + Directory.CreateDirectory(_layout.TrashDir); + + var suffix = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"; + + var moduleDir = Path.Combine(_layout.InstalledDir, id); + if (Directory.Exists(moduleDir)) + { + var trash = Path.Combine(_layout.TrashDir, $"{id}-{suffix}"); + Directory.Move(moduleDir, trash); + } + + var packageDir = Path.Combine(_layout.PackagesDir, id); + if (Directory.Exists(packageDir)) + { + var trash = Path.Combine(_layout.TrashDir, $"{id}-packages-{suffix}"); + Directory.Move(packageDir, trash); + } + + state.Modules.RemoveAll(m => string.Equals(m.Id, id, StringComparison.Ordinal)); + await _stateStore.SaveAsync(state); + return OperationResult.Ok(); + } + catch (Exception ex) + { + return OperationResult.Fail($"删除失败:{ex.Message}(建议先停用并重启后再删除)"); + } + } + + public async Task RemoveModuleVersionAsync(string id, string version) + { + id = (id ?? "").Trim(); + version = (version ?? "").Trim(); + if (id.Length == 0 || version.Length == 0) + return OperationResult.Fail("参数无效"); + + var state = await _stateStore.LoadAsync(); + var item = state.Modules.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.Ordinal)); + if (item == null) + return OperationResult.Fail("模块不存在"); + + if (item.BuiltIn) + return OperationResult.Fail("内置模块不支持删除版本"); + + if (string.Equals((item.ActiveVersion ?? "").Trim(), version, StringComparison.Ordinal)) + return OperationResult.Fail("不能删除当前启用版本,请先切换 ActiveVersion"); + + try + { + Directory.CreateDirectory(_layout.TrashDir); + var suffix = $"{DateTime.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}"; + + var versionDir = Path.Combine(_layout.InstalledDir, id, version); + if (Directory.Exists(versionDir)) + { + var trash = Path.Combine(_layout.TrashDir, $"{id}-{version}-{suffix}"); + Directory.Move(versionDir, trash); + } + + var packageFile = Path.Combine(_layout.PackagesDir, id, $"{version}.tpm"); + if (File.Exists(packageFile)) + { + var trash = Path.Combine(_layout.TrashDir, $"{id}-{version}-package-{suffix}.tpm"); + File.Move(packageFile, trash); + } + + item.InstalledVersions ??= new List(); + item.InstalledVersions.RemoveAll(v => string.Equals(v, version, StringComparison.Ordinal)); + if (string.Equals((item.LastGoodVersion ?? "").Trim(), version, StringComparison.Ordinal)) + item.LastGoodVersion = null; + + // 如果删到一个版本都不剩,则等价于删除模块 + if (item.InstalledVersions.Count == 0) + state.Modules.RemoveAll(m => string.Equals(m.Id, id, StringComparison.Ordinal)); + + await _stateStore.SaveAsync(state); + return OperationResult.Ok(); + } + catch (Exception ex) + { + return OperationResult.Fail($"删除版本失败:{ex.Message}(建议先停用并重启后再删除)"); + } + } + + public async Task PruneOldVersionsAsync(string id) + { + id = (id ?? "").Trim(); + if (id.Length == 0) + return OperationResult.Fail("id 不能为空"); + + var state = await _stateStore.LoadAsync(); + var item = state.Modules.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.Ordinal)); + if (item == null) + return OperationResult.Fail("模块不存在"); + + if (item.BuiltIn) + return OperationResult.Fail("内置模块不支持清理版本"); + + item.InstalledVersions ??= new List(); + var active = (item.ActiveVersion ?? "").Trim(); + var lastGood = (item.LastGoodVersion ?? "").Trim(); + var keep = new HashSet(StringComparer.Ordinal); + if (!string.IsNullOrWhiteSpace(active)) keep.Add(active); + if (!string.IsNullOrWhiteSpace(lastGood)) keep.Add(lastGood); + + var toRemove = item.InstalledVersions + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct(StringComparer.Ordinal) + .Where(v => !keep.Contains(v)) + .ToList(); + + if (toRemove.Count == 0) + return OperationResult.Ok(); + + foreach (var v in toRemove) + { + var r = await RemoveModuleVersionAsync(id, v); + if (!r.Success) + return r; } - var packageDir = Path.Combine(_layout.PackagesDir, id); - if (Directory.Exists(packageDir)) - { - var trash = Path.Combine(_layout.TrashDir, $"{id}-packages-{DateTime.UtcNow:yyyyMMddHHmmss}"); - Directory.Move(packageDir, trash); - } - - state.Modules.RemoveAll(m => string.Equals(m.Id, id, StringComparison.Ordinal)); - await _stateStore.SaveAsync(state); return OperationResult.Ok(); } @@ -365,6 +478,61 @@ public sealed class ModuleInstallerService return null; } + private static void TryPromoteSingleRootFolder(string stagingDir) + { + if (string.IsNullOrWhiteSpace(stagingDir)) + return; + + try + { + var manifest = Path.Combine(stagingDir, "manifest.json"); + if (File.Exists(manifest)) + return; + + var files = Directory.GetFiles(stagingDir) + .Where(f => !string.Equals(Path.GetFileName(f), ".DS_Store", StringComparison.OrdinalIgnoreCase)) + .ToList(); + var dirs = Directory.GetDirectories(stagingDir) + .Where(d => !string.Equals(Path.GetFileName(d), "__MACOSX", StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (files.Count != 0 || dirs.Count != 1) + return; + + var root = dirs[0]; + var innerManifest = Path.Combine(root, "manifest.json"); + if (!File.Exists(innerManifest)) + return; + + foreach (var entry in Directory.EnumerateFileSystemEntries(root)) + { + var name = Path.GetFileName(entry); + if (string.IsNullOrWhiteSpace(name)) + continue; + + var target = Path.Combine(stagingDir, name); + if (Directory.Exists(entry)) + { + if (Directory.Exists(target)) + Directory.Delete(target, recursive: true); + Directory.Move(entry, target); + } + else if (File.Exists(entry)) + { + if (File.Exists(target)) + File.Delete(target); + File.Move(entry, target); + } + } + + Directory.Delete(root, recursive: true); + } + catch + { + // ignore:保持原行为(最终仍会报缺少 manifest.json) + } + } + private string? CheckHostCompatibility(ModuleManifest manifest) { if (!SemVer.TryParse(_hostVersion, out var host)) diff --git a/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs b/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs index 7195d5d..de1e646 100644 --- a/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs +++ b/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs @@ -19,12 +19,32 @@ public sealed class ModuleLoadContext : AssemblyLoadContext protected override Assembly? Load(AssemblyName assemblyName) { + var name = (assemblyName.Name ?? "").Trim(); + if (name.Length == 0) + return null; + + // 1) 与宿主共享的“边界程序集”必须由 Default ALC 解析,避免同名程序集被模块再次加载导致类型身份不一致。 + // 典型问题:模块包携带 Microsoft.Extensions.DependencyInjection*.dll / Microsoft.AspNetCore.Components.dll, + // 会让 IServiceCollection / EventCallback 等类型在不同 ALC 中变成“不同类型”,最终出现: + // - TypeLoadException(看起来像模块没有实现 ConfigureServices) + // - 组件参数绑定失败(表现为 DraftChanged 不生效) + if (name.StartsWith("Microsoft.Extensions.", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Microsoft.AspNetCore.", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("Microsoft.JSInterop", StringComparison.OrdinalIgnoreCase) + || name.StartsWith("TelegramPanel.", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "MudBlazor", StringComparison.OrdinalIgnoreCase)) + return null; + + // 2) 其次:如果宿主已经加载过同名程序集,则同样交给 Default ALC(避免重复加载)。 + if (AssemblyLoadContext.Default.Assemblies.Any(a => + string.Equals(a.GetName().Name, name, StringComparison.OrdinalIgnoreCase))) + return null; + var path = _resolver.ResolveAssemblyToPath(assemblyName); if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) return LoadFromAssemblyPath(path); // fallback:如果模块未提供 deps.json,则在 lib 目录按名称寻找 - var name = (assemblyName.Name ?? "").Trim(); if (name.Length == 0 || string.IsNullOrWhiteSpace(_baseDir)) return null; diff --git a/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs b/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs index 77da0e1..a33c312 100644 --- a/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs @@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging; using TelegramPanel.Core.BatchTasks; using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Services; -using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Data.Entities; using TelegramPanel.Modules; @@ -74,7 +73,6 @@ public sealed class BatchTaskBackgroundService : BackgroundService var taskManagement = scope.ServiceProvider.GetRequiredService(); var channelManagement = scope.ServiceProvider.GetRequiredService(); var channelService = scope.ServiceProvider.GetRequiredService(); - var accountTelegramTools = scope.ServiceProvider.GetRequiredService(); var pending = (await taskManagement.GetTasksByStatusAsync("pending")) .OrderBy(t => t.CreatedAt) @@ -99,9 +97,6 @@ public sealed class BatchTaskBackgroundService : BackgroundService case BatchTaskTypes.SetAdmin: (completed, failed) = await RunSetAdminAsync(pending, taskManagement, channelManagement, channelService, completed, failed, cancellationToken); break; - case BatchTaskTypes.UserJoinSubscribe: - (completed, failed) = await RunUserJoinSubscribeAsync(pending, taskManagement, accountTelegramTools, completed, failed, cancellationToken); - break; default: // 模块扩展任务:从 DI 中查找对应 TaskType 的执行器 var handler = scope.ServiceProvider @@ -272,44 +267,6 @@ public sealed class BatchTaskBackgroundService : BackgroundService return (completed, failed); } - private static async Task<(int completed, int failed)> RunUserJoinSubscribeAsync( - BatchTask task, - BatchTaskManagementService taskManagement, - AccountTelegramToolsService accountTelegramTools, - int completed, - int failed, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(task.Config)) - throw new InvalidOperationException("任务配置为空"); - - var cfg = JsonSerializer.Deserialize(task.Config); - if (cfg == null || cfg.AccountIds.Count == 0 || cfg.Links.Count == 0) - throw new InvalidOperationException("任务配置缺少 AccountIds 或 Links"); - - foreach (var accountId in cfg.AccountIds) - { - foreach (var link in cfg.Links) - { - cancellationToken.ThrowIfCancellationRequested(); - - var latest = await taskManagement.GetTaskAsync(task.Id); - if (latest != null && latest.Status != "running") - return (completed, failed); - - var (ok, _, _) = await accountTelegramTools.JoinChatOrChannelAsync(accountId, link, cancellationToken); - if (ok) completed++; else failed++; - - await taskManagement.UpdateTaskProgressAsync(task.Id, completed, failed); - - if (cfg.DelayMs > 0) - await Task.Delay(cfg.DelayMs + Random.Shared.Next(300, 900), cancellationToken); - } - } - - return (completed, failed); - } - private sealed record InviteTaskConfig( List ChannelTelegramIds, List Usernames, @@ -325,12 +282,6 @@ public sealed class BatchTaskBackgroundService : BackgroundService int PreferredExecuteAccountId ); - private sealed record UserJoinSubscribeTaskConfig( - List AccountIds, - List Links, - int DelayMs - ); - private sealed class DbBackedModuleTaskExecutionHost : IModuleTaskExecutionHost { private readonly BatchTask _task; diff --git a/tools/package-module.ps1 b/tools/package-module.ps1 new file mode 100644 index 0000000..d6dcaea --- /dev/null +++ b/tools/package-module.ps1 @@ -0,0 +1,68 @@ +param( + [Parameter(Mandatory = $false)] + [string]$Project = "src/TelegramPanel.Modules.UserJoinSubscribe/TelegramPanel.Modules.UserJoinSubscribe.csproj", + + [Parameter(Mandatory = $false)] + [string]$Manifest = "src/TelegramPanel.Modules.UserJoinSubscribe/manifest.json", + + [Parameter(Mandatory = $false)] + [string]$OutDir = "artifacts/modules" +) + +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path ".").Path +$projectPath = Join-Path $repoRoot $Project +$manifestPath = Join-Path $repoRoot $Manifest +$outRoot = Join-Path $repoRoot $OutDir + +if (-not (Test-Path -Path $projectPath)) { throw "Project 不存在:$Project" } +if (-not (Test-Path -Path $manifestPath)) { throw "Manifest 不存在:$Manifest" } + +$manifestObj = Get-Content -Path $manifestPath -Raw | ConvertFrom-Json +$moduleId = "" +$version = "" +if ($null -ne $manifestObj.id) { $moduleId = [string]$manifestObj.id } +if ($null -ne $manifestObj.version) { $version = [string]$manifestObj.version } +$moduleId = $moduleId.Trim() +$version = $version.Trim() +if ([string]::IsNullOrWhiteSpace($moduleId)) { throw "manifest.json 缺少 id" } +if ([string]::IsNullOrWhiteSpace($version)) { throw "manifest.json 缺少 version" } + +$buildRootRel = "artifacts/_modulebuild/$moduleId/$version" +$publishRel = "$buildRootRel/publish" +$stagingRel = "$buildRootRel/staging" + +$publishHost = Join-Path $repoRoot $publishRel +$stagingHost = Join-Path $repoRoot $stagingRel + +New-Item -ItemType Directory -Force -Path $publishHost | Out-Null +New-Item -ItemType Directory -Force -Path $stagingHost | Out-Null + +$publishContainer = "/src/$publishRel" +$projectContainer = "/src/$Project" + +Write-Host "Building module with Docker..." -ForegroundColor Cyan +docker run --rm ` + -v "${repoRoot}:/src" ` + -w "/src" ` + mcr.microsoft.com/dotnet/sdk:8.0 ` + dotnet publish "$projectContainer" -c Release -o "$publishContainer" /p:UseAppHost=false + +$stagingLib = Join-Path $stagingHost "lib" +New-Item -ItemType Directory -Force -Path $stagingLib | Out-Null + +Copy-Item -Path $manifestPath -Destination (Join-Path $stagingHost "manifest.json") -Force +Copy-Item -Path (Join-Path $publishHost "*") -Destination $stagingLib -Recurse -Force + +New-Item -ItemType Directory -Force -Path $outRoot | Out-Null +$dest = Join-Path $outRoot "$moduleId-$version.tpm" +if (Test-Path -Path $dest) { Remove-Item -Path $dest -Force } + +$destZip = [System.IO.Path]::ChangeExtension($dest, ".zip") +if (Test-Path -Path $destZip) { Remove-Item -Path $destZip -Force } + +Compress-Archive -Path (Join-Path $stagingHost "*") -DestinationPath $destZip -Force +Move-Item -Path $destZip -Destination $dest -Force + +Write-Host "OK: $dest" -ForegroundColor Green