diff --git a/README.md b/README.md index b861954..fad962d 100644 --- a/README.md +++ b/README.md @@ -259,3 +259,4 @@ curl -X POST "http://localhost:5000/api/kick" \ - `docs/api.md`(接口速查) - `docs/database.md`(数据库/表结构说明) - `docs/advanced.md`(配置项/数据目录/后台任务等) +- `docs/modules.md`(模块系统:可安装/可卸载/依赖/回滚) diff --git a/TelegramPanel.sln b/TelegramPanel.sln index 0eb2450..301f599 100644 --- a/TelegramPanel.sln +++ b/TelegramPanel.sln @@ -8,6 +8,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Core", "src\T EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Data", "src\TelegramPanel.Data\TelegramPanel.Data.csproj", "{C3D4E5F6-7890-1234-CDEF-123456789012}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TelegramPanel.Modules.Abstractions", "src\TelegramPanel.Modules.Abstractions\TelegramPanel.Modules.Abstractions.csproj", "{D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -26,5 +28,9 @@ Global {C3D4E5F6-7890-1234-CDEF-123456789012}.Debug|Any CPU.Build.0 = Debug|Any CPU {C3D4E5F6-7890-1234-CDEF-123456789012}.Release|Any CPU.ActiveCfg = Release|Any CPU {C3D4E5F6-7890-1234-CDEF-123456789012}.Release|Any CPU.Build.0 = Release|Any CPU + {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D47E13D1-8E3C-4F40-9B6D-4B8E17A8C5F1}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/docs/README.md b/docs/README.md index ec5acf5..809a727 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,3 +10,4 @@ - `docs/api.md`:接口速查(面向二次开发) - `docs/database.md`:数据库与主要表结构(面向排障/扩展) - `docs/advanced.md`:架构/配置项/环境变量/数据持久化位置等 +- `docs/modules.md`:模块系统(可安装/可卸载/版本与依赖/回滚) diff --git a/docs/modules.md b/docs/modules.md new file mode 100644 index 0000000..d4b02a3 --- /dev/null +++ b/docs/modules.md @@ -0,0 +1,259 @@ +# 模块系统(可安装/可卸载) + +本项目提供一个“模块系统”框架,用于把**任务能力**与**外部 API 能力**以模块形式分发、安装、启用与回滚,避免因为扩展功能不兼容导致主站不可用。 + +> 当前实现为**同进程插件**(动态加载程序集)。为稳定起见:安装/启用/停用/卸载后通常需要**重启服务**才能生效。 + +## 目标 + +- 可安装/可卸载:面板内上传模块包并管理启用状态 +- 版本管理:同一模块可安装多个版本,支持切换 `ActiveVersion` +- 依赖管理:模块声明依赖的模块与版本范围(`>=1.2.3 <2.0.0`) +- 兼容性:模块声明宿主版本区间(`host.min/host.max`) +- 失败自动兜底:模块加载失败时自动尝试回滚到 `LastGoodVersion`,否则自动禁用以避免拖垮系统 + +## 扩展点一览(任务 / API / UI) + +模块除 `ConfigureServices` / `MapEndpoints` 外,还可以选择性实现以下接口(位于 `TelegramPanel.Modules.Abstractions`): + +- `IModuleTaskProvider`:声明模块提供的任务类型(让任务中心可动态展示/创建) +- `IModuleTaskHandler`:实现任务中心后台执行器(让后台真正能跑该任务) +- `IModuleApiProvider`:声明模块提供的外部 API 类型(让 API 管理页面可动态创建配置项) +- `IModuleUiProvider`:声明模块扩展 UI 导航与页面(让面板可挂载模块自定义页面) + +> 说明:模块启用/停用通常需要重启;宿主启动时只会加载“启用”的模块,因此 UI/任务/API 列表会随启用状态变化。 + +## 模块目录结构 + +模块默认使用持久化目录(Docker 内默认:`/data/modules`;可用配置 `Modules:RootPath` 覆盖): + +``` +modules/ + state.json + packages/ + / + .tpm + installed/ + / + / + manifest.json + lib/ + .dll + ...依赖 dll... + ...其他资源文件... + staging/ # 安装中临时目录 + trash/ # 删除后回收目录(可手动找回) +``` + +`state.json` 记录模块是否启用、当前使用版本与 last-good: + +```json +{ + "schemaVersion": 1, + "modules": [ + { + "id": "builtin.kick-api", + "enabled": true, + "activeVersion": "1.2.3", + "lastGoodVersion": "1.2.3", + "installedVersions": ["1.2.3"], + "builtIn": true + } + ] +} +``` + +## 模块包格式(.tpm / .zip) + +模块包本质是 Zip 文件(扩展名可为 `.tpm` 或 `.zip`),解压后的根目录必须包含: + +- `manifest.json` +- `lib/.dll`(入口程序集) + +安装流程会先解压到 `staging/` 并做基础校验,然后移动到 `installed///`,并将原包存档到 `packages//.tpm` 便于留档与回滚。 + +## manifest.json(示例) + +```json +{ + "id": "example.kick-api", + "name": "示例:踢人 API", + "version": "1.0.0", + "host": { "min": "1.0.0", "max": "2.0.0" }, + "dependencies": [ + { "id": "builtin.kick-api", "range": ">=1.0.0 <2.0.0" } + ], + "entry": { + "assembly": "Example.KickApi.dll", + "type": "Example.KickApi.ExampleKickApiModule" + } +} +``` + +版本范围(`dependencies[].range`)支持: + +- `1.2.3`(等于) +- `>=1.2.3` +- `>=1.2.3 <2.0.0`(空格分隔多个条件) + +## 模块代码示例(入口点) + +模块入口类型需实现 `TelegramPanel.Modules.ITelegramPanelModule`: + +```csharp +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using TelegramPanel.Modules; + +namespace Example.KickApi; + +public sealed class ExampleKickApiModule : ITelegramPanelModule +{ + public ModuleManifest Manifest { get; } = new() + { + Id = "example.kick-api", + Name = "示例:踢人 API", + Version = "1.0.0", + Host = new HostCompatibility { Min = "1.0.0", Max = "2.0.0" }, + Entry = new ModuleEntryPoint { Assembly = "Example.KickApi.dll", Type = typeof(ExampleKickApiModule).FullName! } + }; + + public void ConfigureServices(IServiceCollection services, ModuleHostContext context) + { + // 可在这里注册该模块用到的 DI 服务(注意:启用/停用通常需要重启才能生效) + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) + { + endpoints.MapPost("/api/example", () => Results.Ok(new { ok = true })); + } +} +``` + +## 任务扩展(Task) + +### 1) 声明任务类型(可在“新建任务”中出现) + +实现 `IModuleTaskProvider` 返回 `ModuleTaskDefinition`: + +```csharp +public sealed class MyTaskModule : ITelegramPanelModule, IModuleTaskProvider +{ + public IEnumerable GetTasks(ModuleHostContext context) + { + yield return new ModuleTaskDefinition + { + Category = "user", + TaskType = "my_task_type", + DisplayName = "我的任务", + Description = "自定义任务说明", + Icon = MudBlazor.Icons.Material.Filled.Task, + Order = 100 + }; + } +} +``` + +### 2) 实现任务执行器(后台真正运行) + +实现 `IModuleTaskHandler` 并在 `ConfigureServices` 注册到 DI: + +```csharp +public sealed class MyTaskHandler : IModuleTaskHandler +{ + public string TaskType => "my_task_type"; + + public async Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken ct) + { + // host.Config 是创建任务时写入的 Config 字符串(建议是 JSON) + // host.Services 可解析宿主的服务(AccountTelegramToolsService 等) + // host.UpdateProgressAsync(...) 用于写入任务中心进度 + + var completed = 0; + var failed = 0; + + // 示例:跑 10 步 + for (var i = 0; i < 10; i++) + { + ct.ThrowIfCancellationRequested(); + if (!await host.IsStillRunningAsync(ct)) + return; + + completed++; + await host.UpdateProgressAsync(completed, failed, ct); + } + } +} + +public void ConfigureServices(IServiceCollection services, ModuleHostContext context) +{ + services.AddSingleton(); +} +``` + +### 3) 可选:提供自定义创建器(任务中心内嵌表单) + +在 `ModuleTaskDefinition.EditorComponentType` 指定组件类型的 `AssemblyQualifiedName`,并实现组件参数: + +- `Draft`(`ModuleTaskDraft`) +- `DraftChanged`(`EventCallback`) + +宿主会用 `DynamicComponent` 渲染该组件,提交时使用 `Draft.Total` / `Draft.Config` 创建任务。 + +## 外部 API 扩展(API) + +### 1) 声明 API 类型(可在“API 管理→新建 API”中出现) + +实现 `IModuleApiProvider` 返回 `ModuleApiTypeDefinition`: + +```csharp +public IEnumerable GetApis(ModuleHostContext context) +{ + yield return new ModuleApiTypeDefinition + { + Type = "my_api", + DisplayName = "我的 API", + Route = "/api/my", + Description = "自定义接口说明", + Order = 100 + }; +} +``` + +### 2) 映射 endpoints 并读取配置项 + +宿主会把 API 配置写入 `ExternalApi:Apis`(含 `Type` / `Enabled` / `ApiKey` / `Config(JSON object)`)。模块在 endpoint 里自行按 `X-API-Key` 匹配对应配置项并执行。 + +> 内置 kick 接口提供了一个参考实现:`src/TelegramPanel.Web/ExternalApi/KickApi.cs` + +## UI 扩展(页面/导航) + +### 1) 添加导航链接(可选) + +实现 `IModuleUiProvider.GetNavItems` 返回 `ModuleNavItem`(Title/Href/Icon/Group/Order)。 + +### 2) 添加模块页面(推荐) + +实现 `IModuleUiProvider.GetPages` 返回 `ModulePageDefinition`: + +- `Key`:页面键(模块内唯一) +- `ComponentType`:组件类型 `AssemblyQualifiedName` + +宿主提供统一入口路由:`/ext/{moduleId}/{pageKey}`,会动态加载并渲染模块组件。 + +## 运行时行为(启用/回滚) + +- 启用模块会进行宿主版本校验与依赖校验(依赖模块必须存在且版本满足范围)。 +- 启动时加载模块: + - 加载失败会尝试回滚到 `LastGoodVersion`; + - 回滚也失败则自动 `Enabled=false`(避免拖垮系统)。 + +## 安全与稳定提示 + +同进程插件无法做到“绝对不崩”。为了降低风险: + +- 只安装可信来源的模块包 +- 出现异常时先停用模块并重启 +- 建议在生产环境使用“灰度/备份”方式试装模块 + +后续如需更强隔离,可以把模块改为“独立进程 Module Host”模式(主站通过 HTTP/gRPC 调用),进一步降低崩溃风险。 diff --git a/src/TelegramPanel.Modules.Abstractions/ITelegramPanelModule.cs b/src/TelegramPanel.Modules.Abstractions/ITelegramPanelModule.cs new file mode 100644 index 0000000..4ec95b5 --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/ITelegramPanelModule.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; + +namespace TelegramPanel.Modules; + +public interface ITelegramPanelModule +{ + ModuleManifest Manifest { get; } + + /// + /// 可选:模块注入自身服务(注意:启用/停用通常需要重启才能生效)。 + /// + void ConfigureServices(IServiceCollection services, ModuleHostContext context); + + /// + /// 可选:模块映射 API endpoints。 + /// + void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context); +} + +public sealed class ModuleHostContext +{ + public ModuleHostContext(string hostVersion, string modulesRootPath) + { + HostVersion = hostVersion; + ModulesRootPath = modulesRootPath; + } + + public string HostVersion { get; } + public string ModulesRootPath { get; } +} + diff --git a/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs new file mode 100644 index 0000000..9e7cf39 --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/ModuleContributions.cs @@ -0,0 +1,139 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Modules; + +public interface IModuleUiProvider +{ + IEnumerable GetNavItems(ModuleHostContext context); + IEnumerable GetPages(ModuleHostContext context); +} + +public sealed class ModuleNavItem +{ + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + [JsonPropertyName("href")] + public string Href { get; set; } = ""; + + [JsonPropertyName("icon")] + public string Icon { get; set; } = ""; + + [JsonPropertyName("group")] + public string? Group { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } = 0; +} + +public sealed class ModulePageDefinition +{ + [JsonPropertyName("key")] + public string Key { get; set; } = ""; + + [JsonPropertyName("title")] + public string Title { get; set; } = ""; + + [JsonPropertyName("icon")] + public string Icon { get; set; } = ""; + + /// + /// 组件类型的 AssemblyQualifiedName(用于 DynamicComponent 渲染)。 + /// + [JsonPropertyName("componentType")] + public string ComponentType { get; set; } = ""; + + [JsonPropertyName("group")] + public string? Group { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } = 0; +} + +public interface IModuleTaskProvider +{ + IEnumerable GetTasks(ModuleHostContext context); +} + +public sealed class ModuleTaskDefinition +{ + /// + /// 任务分类:例如 user / bot / system(建议使用小写)。 + /// + [JsonPropertyName("category")] + public string Category { get; set; } = ""; + + /// + /// 任务类型常量(数据库 BatchTask.TaskType)。 + /// + [JsonPropertyName("taskType")] + public string TaskType { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("icon")] + public string Icon { get; set; } = ""; + + /// + /// 如果提供了创建页面路由,则任务中心“新建任务”会跳转到该页面创建。 + /// + [JsonPropertyName("createRoute")] + public string? CreateRoute { get; set; } + + /// + /// 任务创建编辑器组件类型 AssemblyQualifiedName(可选)。\n /// 该组件需要支持参数:\n /// - Draft (ModuleTaskDraft)\n /// - DraftChanged (EventCallback<ModuleTaskDraft>)\n /// + [JsonPropertyName("editorComponentType")] + public string? EditorComponentType { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } = 0; +} + +public readonly record struct ModuleTaskDraft(int Total, string? Config, bool CanSubmit, string? ValidationError); + +public interface IModuleApiProvider +{ + IEnumerable GetApis(ModuleHostContext context); +} + +public sealed class ModuleApiTypeDefinition +{ + [JsonPropertyName("type")] + public string Type { get; set; } = ""; + + [JsonPropertyName("displayName")] + public string DisplayName { get; set; } = ""; + + [JsonPropertyName("route")] + public string Route { get; set; } = ""; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("order")] + public int Order { get; set; } = 0; +} + +public interface IModuleTaskHandler +{ + string TaskType { get; } + Task ExecuteAsync(IModuleTaskExecutionHost host, CancellationToken cancellationToken); +} + +public interface IModuleTaskExecutionHost +{ + int TaskId { get; } + string TaskType { get; } + int Total { get; } + string? Config { get; } + + IServiceProvider Services { get; } + + Task IsStillRunningAsync(CancellationToken cancellationToken); + Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken); +} + diff --git a/src/TelegramPanel.Modules.Abstractions/ModuleManifest.cs b/src/TelegramPanel.Modules.Abstractions/ModuleManifest.cs new file mode 100644 index 0000000..2bd46a2 --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/ModuleManifest.cs @@ -0,0 +1,58 @@ +using System.Text.Json.Serialization; + +namespace TelegramPanel.Modules; + +public sealed class ModuleManifest +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("version")] + public string Version { get; set; } = "0.0.0"; + + [JsonPropertyName("host")] + public HostCompatibility Host { get; set; } = new(); + + [JsonPropertyName("dependencies")] + public List Dependencies { get; set; } = new(); + + [JsonPropertyName("entry")] + public ModuleEntryPoint Entry { get; set; } = new(); +} + +public sealed class HostCompatibility +{ + [JsonPropertyName("min")] + public string? Min { get; set; } + + [JsonPropertyName("max")] + public string? Max { get; set; } +} + +public sealed class ModuleDependency +{ + [JsonPropertyName("id")] + public string Id { get; set; } = ""; + + /// + /// 版本范围表达式,支持: + /// - 1.2.3(等于) + /// - >=1.2.3 + /// - >=1.2.3 <2.0.0(空格分隔多个条件) + /// + [JsonPropertyName("range")] + public string Range { get; set; } = ""; +} + +public sealed class ModuleEntryPoint +{ + [JsonPropertyName("assembly")] + public string Assembly { get; set; } = ""; + + [JsonPropertyName("type")] + public string Type { get; set; } = ""; +} + diff --git a/src/TelegramPanel.Modules.Abstractions/SemVer.cs b/src/TelegramPanel.Modules.Abstractions/SemVer.cs new file mode 100644 index 0000000..9e7fbca --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/SemVer.cs @@ -0,0 +1,143 @@ +namespace TelegramPanel.Modules; + +public readonly record struct SemVer(int Major, int Minor, int Patch) : IComparable +{ + public static bool TryParse(string? value, out SemVer ver) + { + ver = default; + value = (value ?? string.Empty).Trim(); + if (value.Length == 0) + return false; + + // 只支持 x.y.z,忽略预发布/构建元数据 + var parts = value.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 1 || parts.Length > 3) + return false; + + if (!int.TryParse(parts[0], out var major)) + return false; + var minor = 0; + var patch = 0; + if (parts.Length >= 2 && !int.TryParse(parts[1], out minor)) + return false; + if (parts.Length >= 3 && !int.TryParse(parts[2], out patch)) + return false; + + ver = new SemVer(major, minor, patch); + return true; + } + + public int CompareTo(SemVer other) + { + var c = Major.CompareTo(other.Major); + if (c != 0) return c; + c = Minor.CompareTo(other.Minor); + if (c != 0) return c; + return Patch.CompareTo(other.Patch); + } + + public override string ToString() => $"{Major}.{Minor}.{Patch}"; +} + +public sealed class VersionRange +{ + private readonly List> _predicates = new(); + + public static bool TryParse(string? expression, out VersionRange range, out string error) + { + range = new VersionRange(); + error = ""; + + expression = (expression ?? string.Empty).Trim(); + if (expression.Length == 0) + { + error = "range 为空"; + return false; + } + + var parts = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries); + foreach (var raw in parts) + { + var token = raw.Trim(); + if (token.Length == 0) + continue; + + if (SemVer.TryParse(token, out var exact)) + { + range._predicates.Add(v => v == exact); + continue; + } + + if (token.StartsWith(">=", StringComparison.Ordinal)) + { + if (!SemVer.TryParse(token.Substring(2), out var min)) + { + error = $"无法解析版本:{token}"; + return false; + } + + range._predicates.Add(v => v.CompareTo(min) >= 0); + continue; + } + + if (token.StartsWith(">", StringComparison.Ordinal)) + { + if (!SemVer.TryParse(token.Substring(1), out var min)) + { + error = $"无法解析版本:{token}"; + return false; + } + + range._predicates.Add(v => v.CompareTo(min) > 0); + continue; + } + + if (token.StartsWith("<=", StringComparison.Ordinal)) + { + if (!SemVer.TryParse(token.Substring(2), out var max)) + { + error = $"无法解析版本:{token}"; + return false; + } + + range._predicates.Add(v => v.CompareTo(max) <= 0); + continue; + } + + if (token.StartsWith("<", StringComparison.Ordinal)) + { + if (!SemVer.TryParse(token.Substring(1), out var max)) + { + error = $"无法解析版本:{token}"; + return false; + } + + range._predicates.Add(v => v.CompareTo(max) < 0); + continue; + } + + error = $"不支持的 range token:{token}"; + return false; + } + + if (range._predicates.Count == 0) + { + error = "range 无有效条件"; + return false; + } + + return true; + } + + public bool Contains(SemVer version) + { + foreach (var p in _predicates) + { + if (!p(version)) + return false; + } + + return true; + } +} + diff --git a/src/TelegramPanel.Modules.Abstractions/TelegramPanel.Modules.Abstractions.csproj b/src/TelegramPanel.Modules.Abstractions/TelegramPanel.Modules.Abstractions.csproj new file mode 100644 index 0000000..f6ae2d3 --- /dev/null +++ b/src/TelegramPanel.Modules.Abstractions/TelegramPanel.Modules.Abstractions.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + TelegramPanel.Modules + + + + + + + + + + + diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor index b5c3c60..887608c 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateBatchTaskDialog.razor @@ -3,12 +3,15 @@ @inject AccountCategoryManagementService CategoryManagement @inject TelegramPanel.Core.Services.AccountRiskService RiskService @inject BatchTaskManagementService TaskManagement +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject ISnackbar Snackbar @inject IDialogService DialogService @inject NavigationManager Navigation @using System.Text.Json +@using Microsoft.AspNetCore.Components @using TelegramPanel.Core.BatchTasks @using TelegramPanel.Data.Entities +@using TelegramPanel.Web.Modules @@ -20,8 +23,10 @@ - 用户任务 - Bot 任务 + @foreach (var c in AvailableCategories) + { + @GetCategoryDisplayName(c) + } @@ -96,23 +101,39 @@ } - else if (selectedTaskType == BatchTaskTypes.Invite) + else if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) { - 当前版本暂未在任务中心内置“批量邀请”的创建表单,请使用“批量邀请”页面创建任务。 + 该任务建议使用独立页面创建:@CurrentOption.CreateRoute - - 前往批量邀请 + + 前往创建 } - else if (selectedTaskType == BatchTaskTypes.SetAdmin) + else if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null) { - 当前版本暂未在任务中心内置“设置管理员”的创建表单,请使用“批量设置管理员”页面创建任务。 + 该任务由模块提供自定义创建器。 - - 前往批量设置管理员 - + + + + @if (!string.IsNullOrWhiteSpace(editorDraft.ValidationError)) + { + @editorDraft.ValidationError + } + } + else + { + + 该任务类型暂无专用创建表单。你可以填写通用配置(JSON 字符串)提交任务;对应模块需要在后台实现该 TaskType 的执行器。 + + + + + } @@ -136,28 +157,20 @@ { [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; - private sealed record TaskOption(string Category, string TaskType, string DisplayName, string? Description); - - private static readonly List Options = new() - { - new("bot", BatchTaskTypes.Invite, "批量邀请", "对所选频道批量邀请用户名列表(Bot/执行账号需具备权限)。"), - new("bot", BatchTaskTypes.SetAdmin, "设置管理员", "对所选频道批量设置管理员(Bot/执行账号需具备权限)。"), - new("user", BatchTaskTypes.UserJoinSubscribe, "批量订阅/加群", "用户账号批量加入群组/订阅频道(Bot 无法主动加入)。") - }; - - private string _selectedCategory = "user"; + private string _selectedCategory = ""; private string selectedCategory { get => _selectedCategory; set { - if (string.Equals(_selectedCategory, value, StringComparison.Ordinal)) + value = (value ?? "").Trim(); + if (string.Equals(_selectedCategory, value, StringComparison.OrdinalIgnoreCase)) return; _selectedCategory = value; EnsureTaskTypeValid(); } } - private string selectedTaskType = BatchTaskTypes.UserJoinSubscribe; + private string selectedTaskType = ""; private bool creating; @@ -165,6 +178,15 @@ private string linksText = ""; private int delayMs = 2000; + // generic task config + private string genericConfigJson = ""; + private int genericTotal = 0; + + // module editor draft + private TelegramPanel.Modules.ModuleTaskDraft editorDraft = new(0, null, false, null); + private Type? editorComponentType; + private Dictionary editorParameters = new(); + private bool loadingAccounts; private List accounts = new(); private List categories = new(); @@ -172,8 +194,27 @@ private int filterAccountCategoryId = 0; private HashSet selectedAccounts = new(); - private IEnumerable AvailableOptions => Options.Where(o => o.Category == selectedCategory); - private TaskOption? CurrentOption => Options.FirstOrDefault(o => o.TaskType == selectedTaskType); + private IEnumerable AvailableCategories => + Contributions.Tasks + .Select(x => (x.Definition.Category ?? "").Trim()) + .Where(x => x.Length > 0) + .Where(x => !IsSystemCategory(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase); + + private IEnumerable AvailableOptions => + Contributions.Tasks + .Select(x => x.Definition) + .Where(o => !IsSystemCategory(o.Category)) + .Where(o => string.Equals(o.Category, selectedCategory, StringComparison.OrdinalIgnoreCase)) + .OrderBy(o => o.Order) + .ThenBy(o => o.DisplayName); + + private TelegramPanel.Modules.ModuleTaskDefinition? CurrentOption => + !string.IsNullOrWhiteSpace(selectedTaskType) + && Contributions.TaskTypeToDefinition.TryGetValue(selectedTaskType, out var reg) + ? reg.Definition + : null; private IEnumerable FilteredAccounts => accounts.Where(a => @@ -193,7 +234,11 @@ protected override async Task OnInitializedAsync() { + if (string.IsNullOrWhiteSpace(selectedCategory)) + selectedCategory = AvailableCategories.FirstOrDefault() ?? "user"; + EnsureTaskTypeValid(); + ResolveEditorComponent(); await LoadCategories(); await LoadAccounts(); } @@ -232,11 +277,58 @@ private void EnsureTaskTypeValid() { - if (AvailableOptions.All(o => o.TaskType != selectedTaskType)) + if (AvailableOptions.All(o => !string.Equals(o.TaskType, selectedTaskType, StringComparison.OrdinalIgnoreCase))) { selectedTaskType = AvailableOptions.FirstOrDefault()?.TaskType - ?? Options.First().TaskType; + ?? Contributions.Tasks.FirstOrDefault()?.Definition.TaskType + ?? BatchTaskTypes.UserJoinSubscribe; } + + ResolveEditorComponent(); + } + + private static string GetCategoryDisplayName(string category) => category switch + { + "user" => "用户任务", + "bot" => "Bot 任务", + "system" => "系统任务", + _ => string.IsNullOrWhiteSpace(category) ? "-" : category + }; + + private static bool IsSystemCategory(string? category) => + string.Equals((category ?? "").Trim(), "system", StringComparison.OrdinalIgnoreCase); + + private void ResolveEditorComponent() + { + editorComponentType = null; + editorParameters = new Dictionary(); + editorDraft = new TelegramPanel.Modules.ModuleTaskDraft(0, null, false, null); + + var opt = CurrentOption; + if (opt == null || string.IsNullOrWhiteSpace(opt.EditorComponentType)) + return; + + if (!Contributions.TaskTypeToDefinition.TryGetValue(opt.TaskType, out var reg)) + return; + + var typeName = opt.EditorComponentType.Trim(); + editorComponentType = + Type.GetType(typeName, throwOnError: false, ignoreCase: false) + ?? reg.Module.Instance.GetType().Assembly.GetType(typeName, throwOnError: false, ignoreCase: false); + + if (editorComponentType == null) + return; + + editorParameters = new Dictionary + { + ["Draft"] = editorDraft, + ["DraftChanged"] = EventCallback.Factory.Create(this, OnEditorDraftChanged) + }; + } + + private void OnEditorDraftChanged(TelegramPanel.Modules.ModuleTaskDraft draft) + { + editorDraft = draft; } private void SelectAll() => selectedAccounts = FilteredAccounts.ToHashSet(); @@ -256,84 +348,173 @@ EnsureTaskTypeValid(); - if (selectedTaskType != BatchTaskTypes.UserJoinSubscribe) + if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.CreateRoute)) { - Snackbar.Add("当前任务类型暂不支持从任务中心直接创建", Severity.Info); + Snackbar.Add("该任务建议使用独立页面创建,请点击“前往创建”。", Severity.Info); return; } - var links = ParseLines(linksText); - if (links.Count == 0) + if (CurrentOption != null && !string.IsNullOrWhiteSpace(CurrentOption.EditorComponentType) && editorComponentType != null) { - 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 + if (!editorDraft.CanSubmit) { - ["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; + Snackbar.Add(string.IsNullOrWhiteSpace(editorDraft.ValidationError) ? "请先完善任务配置" : editorDraft.ValidationError!, Severity.Warning); + return; + } - if (result.Canceled) + creating = true; + try + { + var task = new BatchTask + { + 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 + { + ["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) + 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; + } + } + } + + bool? confirm = await DialogService.ShowMessageBox( + "确认提交任务", + $"将对 {selected.Count} 个账号执行 {links.Count} 个链接的订阅/加群操作,总计 {selected.Count * links.Count} 次。是否继续?", + yesText: "继续", cancelText: "取消"); + + if (confirm != true) return; - if (result.Data is TelegramPanel.Core.Services.RiskWarningAction action && action == TelegramPanel.Core.Services.RiskWarningAction.ExcludeRisky) + creating = true; + try { - selected = batchCheck.SafeAccounts.ToList(); - if (selected.Count == 0) + var ids = selected.Select(a => a.Id).Distinct().ToList(); + var task = new BatchTask { - Snackbar.Add("排除风险账号后无剩余账号", Severity.Info); - return; - } + TaskType = BatchTaskTypes.UserJoinSubscribe, + Total = ids.Count * links.Count, + Completed = 0, + Failed = 0, + Config = JsonSerializer.Serialize(new + { + AccountIds = ids, + Links = links, + DelayMs = delayMs + }) + }; + + var created = await TaskManagement.CreateTaskAsync(task); + Snackbar.Add($"已提交后台任务 #{created.Id}(批量订阅/加群)。请到【任务中心】查看进度。", Severity.Success); + MudDialog.Close(DialogResult.Ok(created.Id)); + } + catch (Exception ex) + { + Snackbar.Add($"提交失败:{ex.Message}", Severity.Error); + } + finally + { + creating = false; + } + + return; + } + + // Generic task create + if (!string.IsNullOrWhiteSpace(genericConfigJson)) + { + try + { + JsonDocument.Parse(genericConfigJson); + } + catch (Exception ex) + { + Snackbar.Add($"配置 JSON 无效:{ex.Message}", Severity.Warning); + return; } } - bool? confirm = await DialogService.ShowMessageBox( + bool? genericConfirm = await DialogService.ShowMessageBox( "确认提交任务", - $"将对 {selected.Count} 个账号执行 {links.Count} 个链接的订阅/加群操作,总计 {selected.Count * links.Count} 次。是否继续?", + $"将提交任务类型:{selectedTaskType}。是否继续?", yesText: "继续", cancelText: "取消"); - if (confirm != true) + if (genericConfirm != true) return; 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 = genericTotal, Completed = 0, Failed = 0, - Config = JsonSerializer.Serialize(new - { - AccountIds = ids, - Links = links, - DelayMs = delayMs - }) + Config = string.IsNullOrWhiteSpace(genericConfigJson) ? null : genericConfigJson.Trim() }; 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) diff --git a/src/TelegramPanel.Web/Components/Dialogs/CreateExternalApiDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/CreateExternalApiDialog.razor index 5524dca..960d633 100644 --- a/src/TelegramPanel.Web/Components/Dialogs/CreateExternalApiDialog.razor +++ b/src/TelegramPanel.Web/Components/Dialogs/CreateExternalApiDialog.razor @@ -1,6 +1,7 @@ @namespace TelegramPanel.Web.Components.Dialogs @inject ISnackbar Snackbar @using System.Security.Cryptography +@using TelegramPanel.Modules @using TelegramPanel.Web.ExternalApi @@ -14,7 +15,10 @@ HelperText="用于在面板中区分不同的 API 配置项" /> - 踢人/封禁(/api/kick) + @foreach (var t in AvailableTypes) + { + @t.DisplayName(@t.Route) + } @@ -29,11 +33,18 @@ @code { [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + [Parameter] public IReadOnlyList AvailableTypes { get; set; } = Array.Empty(); private string name = ""; - private string type = ExternalApiTypes.Kick; + private string type = ""; private bool enabled; + protected override void OnInitialized() + { + if (string.IsNullOrWhiteSpace(type)) + type = AvailableTypes.FirstOrDefault()?.Type ?? ""; + } + private void Cancel() => MudDialog.Cancel(); private void Confirm() @@ -45,6 +56,12 @@ return; } + if (AvailableTypes.All(x => !string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase))) + { + Snackbar.Add("请选择有效的 API 类型", Severity.Warning); + return; + } + var apiKey = GenerateKey(); var def = new ExternalApiDefinition { @@ -52,14 +69,19 @@ Type = type, Enabled = enabled, ApiKey = apiKey, - Kick = new KickApiDefinition + Config = new System.Text.Json.Nodes.JsonObject() + }; + + if (string.Equals(type, ExternalApiTypes.Kick, StringComparison.OrdinalIgnoreCase)) + { + def.Kick = new KickApiDefinition { BotId = 0, UseAllChats = true, ChatIds = new List(), PermanentBanDefault = false - } - }; + }; + } MudDialog.Close(DialogResult.Ok(def)); } diff --git a/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor b/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor new file mode 100644 index 0000000..b061a6b --- /dev/null +++ b/src/TelegramPanel.Web/Components/Dialogs/EditGenericApiDialog.razor @@ -0,0 +1,129 @@ +@namespace TelegramPanel.Web.Components.Dialogs +@inject ISnackbar Snackbar +@using System.Security.Cryptography +@using System.Text.Json +@using System.Text.Json.Nodes +@using TelegramPanel.Web.ExternalApi + + + + + + 外部接口:@(string.IsNullOrWhiteSpace(Route) ? "-" : Route)(Type: @(string.IsNullOrWhiteSpace(Api.Type) ? "-" : Api.Type)) + + + + + + + + + 重新生成 Key + + + + + + + 取消 + + @if (saving) + { + + 保存中... + } + else + { + 保存 + } + + + + +@code +{ + [CascadingParameter] private MudDialogInstance MudDialog { get; set; } = default!; + + [Parameter] public ExternalApiDefinition Api { get; set; } = new(); + [Parameter] public string? Route { get; set; } + + private bool saving; + private ExternalApiDefinition model = new(); + private string configJson = "{}"; + + protected override void OnInitialized() + { + model = Clone(Api); + var cfg = model.Config ?? new JsonObject(); + configJson = cfg.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + private void Cancel() => MudDialog.Cancel(); + + private void RegenerateKey() + { + var bytes = RandomNumberGenerator.GetBytes(32); + var key = Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); + model.ApiKey = key; + Snackbar.Add("已重新生成 Key(记得保存)", Severity.Info); + } + + private void Save() + { + if (saving) + return; + + var n = (model.Name ?? "").Trim(); + if (string.IsNullOrWhiteSpace(n)) + { + Snackbar.Add("API 名称不能为空", Severity.Warning); + return; + } + + var key = (model.ApiKey ?? "").Trim(); + if (string.IsNullOrWhiteSpace(key)) + { + Snackbar.Add("X-API-Key 不能为空", Severity.Warning); + return; + } + + try + { + var parsed = JsonNode.Parse(configJson ?? "{}"); + if (parsed is not JsonObject obj) + { + Snackbar.Add("Config 必须是 JSON 对象(例如 {})", Severity.Warning); + return; + } + + model.Name = n; + model.ApiKey = key; + model.Config = obj; + } + catch (Exception ex) + { + Snackbar.Add($"Config JSON 无效:{ex.Message}", Severity.Warning); + return; + } + + MudDialog.Close(DialogResult.Ok(model)); + } + + private static ExternalApiDefinition Clone(ExternalApiDefinition src) + { + // 仅用于编辑器内复制(避免直接改引用) + return new ExternalApiDefinition + { + Id = src.Id, + Name = src.Name, + Type = src.Type, + Enabled = src.Enabled, + ApiKey = src.ApiKey, + Kick = src.Kick, + Config = src.Config ?? new JsonObject() + }; + } +} + diff --git a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor index cf16b12..18458b7 100644 --- a/src/TelegramPanel.Web/Components/Layout/NavMenu.razor +++ b/src/TelegramPanel.Web/Components/Layout/NavMenu.razor @@ -1,3 +1,5 @@ +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions + 仪表盘 @@ -38,6 +40,10 @@ 系统设置 + + 模块管理 + + API 管理 @@ -49,4 +55,30 @@ 退出登录 + + @if (Contributions.NavItems.Count > 0 || Contributions.Pages.Count > 0) + { + + @foreach (var item in Contributions.NavItems + .OrderBy(x => x.Definition.Group ?? "", StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Definition.Order) + .ThenBy(x => x.Definition.Title, StringComparer.OrdinalIgnoreCase)) + { + + @item.Definition.Title + + } + + @foreach (var modulePage in Contributions.Pages + .OrderBy(x => x.Definition.Group ?? "", StringComparer.OrdinalIgnoreCase) + .ThenBy(x => x.Definition.Order) + .ThenBy(x => x.Definition.Title, StringComparer.OrdinalIgnoreCase)) + { + var href = $"/ext/{modulePage.Module.Id}/{modulePage.Definition.Key}"; + + @modulePage.Definition.Title + + } + + } diff --git a/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor b/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor index 002df7a..92dc19e 100644 --- a/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor +++ b/src/TelegramPanel.Web/Components/Pages/ApiCenter.razor @@ -7,7 +7,9 @@ @inject IWebHostEnvironment Environment @using System.Text.Json @using System.Text.Json.Nodes +@using TelegramPanel.Modules @using TelegramPanel.Web.ExternalApi +@using TelegramPanel.Web.Modules @using TelegramPanel.Web.Services API 管理 - Telegram Panel @@ -41,9 +43,16 @@ @GetTypeName(context.Type) @(GetRoute(context.Type) ?? "-") - - @(context.Enabled ? "启用" : "停用") - + @if (context.Enabled && !IsTypeAvailable(context.Type)) + { + 停止(模块停用) + } + else + { + + @(context.Enabled ? "启用" : "停用") + + } apis = new(); + [Inject] private ModuleContributionRegistry Contributions { get; set; } = default!; private string LocalConfigPath => LocalConfigFile.ResolvePath(Configuration, Environment); @@ -102,8 +112,13 @@ a.Name = (a.Name ?? "").Trim(); a.Type = (a.Type ?? "").Trim(); a.ApiKey = (a.ApiKey ?? "").Trim(); - a.Kick ??= new KickApiDefinition(); - a.Kick.ChatIds ??= new List(); + a.Config ??= new JsonObject(); + + if (string.Equals(a.Type, ExternalApiTypes.Kick, StringComparison.OrdinalIgnoreCase)) + { + a.Kick ??= new KickApiDefinition(); + a.Kick.ChatIds ??= new List(); + } } return list @@ -112,10 +127,34 @@ .ToList(); } + private bool IsTypeAvailable(string type) + { + return Contributions.ApiTypeToDefinition.ContainsKey(type); + } + + private List GetAvailableApiTypes() + { + return Contributions.ApiTypes + .OrderBy(x => x.Definition.Order) + .ThenBy(x => x.Definition.DisplayName) + .Select(x => x.Definition) + .ToList(); + } + private async Task CreateApi() { + var availableTypes = GetAvailableApiTypes(); + if (availableTypes.Count == 0) + { + Snackbar.Add("当前没有可用的 API 类型(可能对应模块已停用)。请先到「模块管理」启用后重启。", Severity.Warning); + return; + } + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Small, FullWidth = true }; - var dialog = DialogService.Show("新建 API", new DialogParameters(), options)!; + var dialog = DialogService.Show( + "新建 API", + new DialogParameters { ["AvailableTypes"] = availableTypes }, + options)!; var result = await dialog.Result; if (result is null || result.Canceled || result.Data is not ExternalApiDefinition def) return; @@ -141,6 +180,11 @@ private async Task EditApi(ExternalApiDefinition api) { + if (!IsTypeAvailable(api.Type)) + { + Snackbar.Add("该 API 类型对应模块已停用:当前配置不会生效(重启后仍不会提供接口)。", Severity.Warning); + } + if (string.Equals(api.Type, ExternalApiTypes.Kick, StringComparison.OrdinalIgnoreCase)) { var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; @@ -163,7 +207,28 @@ return; } - Snackbar.Add("该 API 类型暂未实现编辑器", Severity.Info); + { + var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Large, FullWidth = true }; + var parameters = new DialogParameters + { + ["Api"] = api, + ["Route"] = GetRoute(api.Type) + }; + var dialog = DialogService.Show("编辑 API", parameters, options)!; + var result = await dialog.Result; + if (result is null || result.Canceled || result.Data is not ExternalApiDefinition updated) + return; + + if (apis.Any(a => a.Id != api.Id && string.Equals(a.ApiKey, updated.ApiKey, StringComparison.Ordinal))) + { + Snackbar.Add("X-API-Key 已存在,请更换后保存", Severity.Warning); + return; + } + + ReplaceInList(updated); + await SaveToLocalConfigAsync(apis); + await Reload(); + } } private async Task DeleteApi(ExternalApiDefinition api) @@ -209,7 +274,11 @@ var url = Navigation.ToAbsoluteUri(route).ToString(); - var curl = $"curl -X POST \"{url}\" \\\n -H \"Content-Type: application/json\" \\\n -H \"X-API-Key: {key}\" \\\n -d '{{\"user_id\": 123456789}}'"; + var body = string.Equals(api.Type, ExternalApiTypes.Kick, StringComparison.OrdinalIgnoreCase) + ? "{\\\"user_id\\\": 123456789}" + : "{}"; + + var curl = $"curl -X POST \"{url}\" \\\n -H \"Content-Type: application/json\" \\\n -H \"X-API-Key: {key}\" \\\n -d '{body}'"; var options = new DialogOptions { CloseOnEscapeKey = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; var parameters = new DialogParameters @@ -219,17 +288,23 @@ DialogService.Show("调用示例(curl)", parameters, options); } - private static string GetTypeName(string type) => type switch + private string GetTypeName(string type) { - ExternalApiTypes.Kick => "踢人/封禁", - _ => string.IsNullOrWhiteSpace(type) ? "-" : type - }; + if (string.IsNullOrWhiteSpace(type)) + return "-"; + return Contributions.ApiTypeToDefinition.TryGetValue(type, out var reg) + ? (string.IsNullOrWhiteSpace(reg.Definition.DisplayName) ? type : reg.Definition.DisplayName) + : type; + } - private static string? GetRoute(string type) => type switch + private string? GetRoute(string type) { - ExternalApiTypes.Kick => "/api/kick", - _ => null - }; + if (string.IsNullOrWhiteSpace(type)) + return null; + return Contributions.ApiTypeToDefinition.TryGetValue(type, out var reg) + ? (string.IsNullOrWhiteSpace(reg.Definition.Route) ? null : reg.Definition.Route) + : null; + } private async Task SaveToLocalConfigAsync(List list) { diff --git a/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor b/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor new file mode 100644 index 0000000..d71ff2b --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/ModulePageHost.razor @@ -0,0 +1,77 @@ +@page "/ext/{ModuleId}/{PageKey}" +@using Microsoft.AspNetCore.Components +@using TelegramPanel.Web.Modules +@inject ModuleContributionRegistry Contributions + +扩展页面 - Telegram Panel + +@if (error != null) +{ + @error +} +else if (componentType == null) +{ + +} +else +{ + +} + +@code +{ + [Parameter] public string ModuleId { get; set; } = ""; + [Parameter] public string PageKey { get; set; } = ""; + + private string? error; + private Type? componentType; + private Dictionary parameters = new(); + + protected override void OnParametersSet() + { + error = null; + componentType = null; + parameters = new Dictionary(); + + var moduleId = (ModuleId ?? "").Trim(); + var pageKey = (PageKey ?? "").Trim(); + if (moduleId.Length == 0 || pageKey.Length == 0) + { + error = "参数无效"; + return; + } + + var page = Contributions.Pages.FirstOrDefault(p => + string.Equals(p.Module.Id, moduleId, StringComparison.OrdinalIgnoreCase) + && string.Equals(p.Definition.Key, pageKey, StringComparison.OrdinalIgnoreCase)); + + if (page == null) + { + error = $"未找到扩展页面:{moduleId}/{pageKey}"; + return; + } + + var typeName = (page.Definition.ComponentType ?? "").Trim(); + if (typeName.Length == 0) + { + error = "模块页面未配置 componentType"; + return; + } + + componentType = + Type.GetType(typeName, throwOnError: false, ignoreCase: false) + ?? page.Module.Instance.GetType().Assembly.GetType(typeName, throwOnError: false, ignoreCase: false); + + if (componentType == null) + { + error = $"无法加载组件类型:{typeName}"; + return; + } + + parameters = new Dictionary + { + ["ModuleId"] = moduleId, + ["PageKey"] = pageKey + }; + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/Modules.razor b/src/TelegramPanel.Web/Components/Pages/Modules.razor new file mode 100644 index 0000000..39c7b67 --- /dev/null +++ b/src/TelegramPanel.Web/Components/Pages/Modules.razor @@ -0,0 +1,273 @@ +@page "/modules" +@inject TelegramPanel.Web.Modules.ModuleInstallerService ModuleInstaller +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions +@inject ISnackbar Snackbar +@inject IDialogService DialogService +@using Microsoft.AspNetCore.Components.Forms +@using TelegramPanel.Modules +@using TelegramPanel.Web.Modules + +模块管理 - Telegram Panel + +模块管理 + + + + + 安装/启用/卸载模块后通常需要重启服务才能生效(避免运行中热插拔导致系统不稳定)。 + + + + + + + 选择模块包(.tpm/.zip) + + + + + + + @(selectedFile == null ? "未选择文件" : $"已选择:{selectedFile.Name}({selectedFile.Size} bytes)") + + + + @if (installing) + { + + 安装中... + } + else + { + 安装 + } + + + + + + + + + @if (Contributions.Diagnostics.Count > 0) + { + + 检测到模块贡献冲突(已自动忽略部分定义): + + @foreach (var d in Contributions.Diagnostics) + { + @d + } + + + } + + + + 已安装模块 (@modules.Count) + + + 刷新 + + + + 模块 + 版本 + 状态 + 操作 + + + + @GetDisplayName(context) + @context.Id + @if (!string.IsNullOrWhiteSpace(context.ManifestError)) + { + @context.ManifestError + } + + + @context.ActiveVersion + @if (!string.IsNullOrWhiteSpace(context.LastGoodVersion)) + { + last-good: @context.LastGoodVersion + } + + + + @(context.Enabled ? "启用" : "停用") + + @if (context.BuiltIn) + { + 内置 + } + + + @if (context.Enabled) + { + + 停用 + + } + else + { + + 启用 + + } + + @if (!context.BuiltIn) + { + + 删除 + + } + + + 详情 + + + + + 暂无模块 + + + + + +@code +{ + private bool loading = true; + private bool installing; + private IBrowserFile? selectedFile; + private List modules = new(); + + protected override async Task OnInitializedAsync() + { + await Reload(); + } + + private async Task Reload() + { + loading = true; + try + { + var list = await ModuleInstaller.GetOverviewAsync(); + modules = list.ToList(); + } + catch (Exception ex) + { + Snackbar.Add($"加载失败:{ex.Message}", Severity.Error); + } + finally + { + loading = false; + } + } + + private void OnFilesChanged(IReadOnlyList files) + { + selectedFile = files.FirstOrDefault(); + } + + private async Task Install() + { + if (selectedFile == null || installing) + return; + + installing = true; + try + { + await using var stream = selectedFile.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024); + var result = await ModuleInstaller.InstallAsync(stream, selectedFile.Name, enableAfterInstall: false); + if (!result.Success) + { + Snackbar.Add($"安装失败:{result.Message}", Severity.Error); + return; + } + + Snackbar.Add($"已安装:{result.ModuleId} {result.Version}(请启用后重启服务生效)", Severity.Success); + selectedFile = null; + await Reload(); + } + catch (Exception ex) + { + Snackbar.Add($"安装异常:{ex.Message}", Severity.Error); + } + finally + { + installing = false; + } + } + + private async Task Enable(string id) + { + var r = await ModuleInstaller.EnableAsync(id); + if (!r.Success) + { + Snackbar.Add($"启用失败:{r.Message}", Severity.Error); + return; + } + + Snackbar.Add("已启用(重启后生效)", Severity.Success); + await Reload(); + } + + private async Task Disable(string id) + { + var r = await ModuleInstaller.DisableAsync(id); + if (!r.Success) + { + Snackbar.Add($"停用失败:{r.Message}", Severity.Error); + return; + } + + Snackbar.Add("已停用(重启后生效)", Severity.Success); + await Reload(); + } + + private async Task Remove(string id) + { + bool? ok = await DialogService.ShowMessageBox( + "确认删除", + $"确定删除模块「{id}」吗?删除会移动到回收站目录(可手动找回),但仍建议先停用并重启确认无依赖。", + yesText: "删除", cancelText: "取消"); + + if (ok != true) + return; + + var r = await ModuleInstaller.RemoveModuleAsync(id); + if (!r.Success) + { + Snackbar.Add($"删除失败:{r.Message}", Severity.Error); + return; + } + + Snackbar.Add("已删除(重启后生效)", Severity.Success); + await Reload(); + } + + private async Task ShowDetails(ModuleOverview m) + { + var manifest = m.Manifest; + var deps = manifest?.Dependencies?.Count > 0 + ? string.Join("\n", manifest.Dependencies.Select(d => $"{d.Id} {d.Range}")) + : "-"; + + var details = + $"Id: {m.Id}\n" + + $"Name: {manifest?.Name ?? "-"}\n" + + $"Version: {m.ActiveVersion ?? "-"}\n" + + $"Enabled: {m.Enabled}\n" + + $"BuiltIn: {m.BuiltIn}\n" + + $"InstalledVersions: {string.Join(", ", m.InstalledVersions)}\n" + + $"Dependencies:\n{deps}\n"; + + await DialogService.ShowMessageBox("模块详情", details); + } + + private static string GetDisplayName(ModuleOverview m) + { + var name = (m.Manifest?.Name ?? "").Trim(); + return string.IsNullOrWhiteSpace(name) ? m.Id : name; + } +} diff --git a/src/TelegramPanel.Web/Components/Pages/Tasks.razor b/src/TelegramPanel.Web/Components/Pages/Tasks.razor index c533ec1..3ed22f0 100644 --- a/src/TelegramPanel.Web/Components/Pages/Tasks.razor +++ b/src/TelegramPanel.Web/Components/Pages/Tasks.razor @@ -1,9 +1,11 @@ @page "/tasks" @inject BatchTaskManagementService TaskManagement +@inject TelegramPanel.Web.Modules.ModuleContributionRegistry Contributions @inject ISnackbar Snackbar @inject IDialogService DialogService @using TelegramPanel.Data.Entities @using TelegramPanel.Core.BatchTasks +@using TelegramPanel.Web.Modules @implements IDisposable 任务中心 - Telegram Panel @@ -24,8 +26,10 @@ 全部 - 用户任务 - Bot 任务 + @foreach (var c in AvailableCategories) + { + @GetCategoryDisplayName(c) + } 全部 @@ -107,6 +111,13 @@ (filterStatus == "all" || t.Status == filterStatus) && (filterCategory == "all" || GetCategory(t.TaskType) == filterCategory)); + private IEnumerable AvailableCategories => + Contributions.Tasks + .Select(x => (x.Definition.Category ?? "").Trim()) + .Where(x => x.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(x => x, StringComparer.OrdinalIgnoreCase); + protected override async Task OnInitializedAsync() { await LoadTasks(); @@ -171,33 +182,61 @@ return (double)task.Completed / task.Total * 100; } - private static string GetCategory(string taskType) => - taskType == BatchTaskTypes.UserJoinSubscribe ? "user" : "bot"; - - private static string GetCategoryName(string taskType) => - GetCategory(taskType) == "user" ? "用户" : "Bot"; - - private static Color GetCategoryColor(string taskType) => - GetCategory(taskType) == "user" ? Color.Info : Color.Secondary; - - private string GetTaskIcon(string type) => type switch + private string GetCategory(string taskType) { - BatchTaskTypes.Invite => Icons.Material.Filled.PersonAdd, - BatchTaskTypes.SetAdmin => Icons.Material.Filled.AdminPanelSettings, - BatchTaskTypes.UserJoinSubscribe => Icons.Material.Filled.GroupAdd, - BatchTaskTypes.ExternalApiKick => Icons.Material.Filled.Link, - _ => Icons.Material.Filled.Task + if (string.IsNullOrWhiteSpace(taskType)) + return "other"; + + if (Contributions.TaskTypeToDefinition.TryGetValue(taskType, out var reg)) + { + var c = (reg.Definition.Category ?? "").Trim(); + return string.IsNullOrWhiteSpace(c) ? "other" : c; + } + + // fallback:兼容历史任务 + return taskType == BatchTaskTypes.UserJoinSubscribe ? "user" : "bot"; + } + + private string GetCategoryName(string taskType) => GetCategoryDisplayName(GetCategory(taskType)); + + private Color GetCategoryColor(string taskType) => GetCategoryColorByCategory(GetCategory(taskType)); + + private static string GetCategoryDisplayName(string category) => category switch + { + "user" => "用户任务", + "bot" => "Bot 任务", + "system" => "系统任务", + _ => string.IsNullOrWhiteSpace(category) ? "-" : category }; - private string GetTaskTypeName(string type) => type switch + private static Color GetCategoryColorByCategory(string category) => category switch { - BatchTaskTypes.Invite => "批量邀请", - BatchTaskTypes.SetAdmin => "设置管理员", - BatchTaskTypes.UserJoinSubscribe => "批量订阅/加群", - BatchTaskTypes.ExternalApiKick => "外部 API:踢人/封禁", - _ => type + "user" => Color.Info, + "bot" => Color.Secondary, + "system" => Color.Default, + _ => Color.Default }; + private string GetTaskIcon(string type) + { + if (!string.IsNullOrWhiteSpace(type) + && Contributions.TaskTypeToDefinition.TryGetValue(type, out var reg) + && !string.IsNullOrWhiteSpace(reg.Definition.Icon)) + return reg.Definition.Icon; + + return Icons.Material.Filled.Task; + } + + private string GetTaskTypeName(string type) + { + if (!string.IsNullOrWhiteSpace(type) + && Contributions.TaskTypeToDefinition.TryGetValue(type, out var reg) + && !string.IsNullOrWhiteSpace(reg.Definition.DisplayName)) + return reg.Definition.DisplayName; + + return type; + } + private string GetStatusName(string status) => status switch { "pending" => "待执行", diff --git a/src/TelegramPanel.Web/ExternalApi/ExternalApiCatalog.cs b/src/TelegramPanel.Web/ExternalApi/ExternalApiCatalog.cs new file mode 100644 index 0000000..f21054f --- /dev/null +++ b/src/TelegramPanel.Web/ExternalApi/ExternalApiCatalog.cs @@ -0,0 +1,31 @@ +namespace TelegramPanel.Web.ExternalApi; + +public static class ExternalApiCatalog +{ + public static readonly ExternalApiTypeInfo Kick = new( + Type: ExternalApiTypes.Kick, + DisplayName: "踢人/封禁", + Route: "/api/kick", + ProviderModuleId: "builtin.kick-api"); + + public static IReadOnlyList All { get; } = new[] + { + Kick + }; + + public static ExternalApiTypeInfo? TryGet(string? type) + { + type = (type ?? string.Empty).Trim(); + if (type.Length == 0) + return null; + + return All.FirstOrDefault(x => string.Equals(x.Type, type, StringComparison.OrdinalIgnoreCase)); + } +} + +public sealed record ExternalApiTypeInfo( + string Type, + string DisplayName, + string Route, + string ProviderModuleId); + diff --git a/src/TelegramPanel.Web/ExternalApi/ExternalApiModels.cs b/src/TelegramPanel.Web/ExternalApi/ExternalApiModels.cs index 7afa097..8f403c5 100644 --- a/src/TelegramPanel.Web/ExternalApi/ExternalApiModels.cs +++ b/src/TelegramPanel.Web/ExternalApi/ExternalApiModels.cs @@ -1,5 +1,7 @@ namespace TelegramPanel.Web.ExternalApi; +using System.Text.Json.Nodes; + public static class ExternalApiTypes { public const string Kick = "kick"; @@ -13,7 +15,15 @@ public sealed class ExternalApiDefinition public bool Enabled { get; set; } public string ApiKey { get; set; } = ""; - public KickApiDefinition Kick { get; set; } = new(); + /// + /// 模块自定义配置(JSON object)。由具体 API 类型自行解释。 + /// + public JsonObject Config { get; set; } = new(); + + /// + /// 兼容内置 kick 的强类型配置(建议同时写入 Config)。 + /// + public KickApiDefinition? Kick { get; set; } = new(); } public sealed class KickApiDefinition diff --git a/src/TelegramPanel.Web/ExternalApi/KickApi.cs b/src/TelegramPanel.Web/ExternalApi/KickApi.cs index 59bebc0..c43bf51 100644 --- a/src/TelegramPanel.Web/ExternalApi/KickApi.cs +++ b/src/TelegramPanel.Web/ExternalApi/KickApi.cs @@ -3,6 +3,7 @@ using System.Text; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.Text.Json; @@ -15,7 +16,7 @@ namespace TelegramPanel.Web.ExternalApi; public static class KickApi { - public static void MapKickApi(this WebApplication app) + public static void MapKickApi(this IEndpointRouteBuilder app) { app.MapPost("/api/kick", HandleAsync) .DisableAntiforgery() @@ -54,6 +55,9 @@ public static class KickApi statusCode: StatusCodes.Status401Unauthorized); } + matched.Kick ??= new KickApiDefinition(); + matched.Kick.ChatIds ??= new List(); + KickRequest? request; try { diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/BuiltInModuleCatalog.cs b/src/TelegramPanel.Web/Modules/BuiltIn/BuiltInModuleCatalog.cs new file mode 100644 index 0000000..c43ea35 --- /dev/null +++ b/src/TelegramPanel.Web/Modules/BuiltIn/BuiltInModuleCatalog.cs @@ -0,0 +1,36 @@ +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Modules.BuiltIn; + +public sealed class BuiltInModuleCatalog +{ + private readonly List _modules; + private readonly Dictionary _manifestById; + + public BuiltInModuleCatalog(string hostVersion) + { + _modules = new List + { + new KickApiModule(hostVersion), + new TaskCatalogModule(hostVersion), + }; + + _manifestById = _modules + .Select(m => m.Manifest) + .ToDictionary(m => m.Id, m => m, StringComparer.Ordinal); + } + + public IReadOnlyList CreateModules() => _modules; + + public bool TryGetManifest(string id, out ModuleManifest manifest) + { + id = (id ?? string.Empty).Trim(); + if (id.Length == 0) + { + manifest = new ModuleManifest(); + return false; + } + + return _manifestById.TryGetValue(id, out manifest!); + } +} diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs new file mode 100644 index 0000000..1bcf14a --- /dev/null +++ b/src/TelegramPanel.Web/Modules/BuiltIn/KickApiModule.cs @@ -0,0 +1,52 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using TelegramPanel.Modules; +using TelegramPanel.Web.ExternalApi; + +namespace TelegramPanel.Web.Modules.BuiltIn; + +public sealed class KickApiModule : ITelegramPanelModule, IModuleApiProvider +{ + public KickApiModule(string version) + { + Manifest = new ModuleManifest + { + Id = "builtin.kick-api", + Name = "外部 API:踢人/封禁", + Version = version, + Host = new HostCompatibility(), + Entry = new ModuleEntryPoint { Assembly = "", Type = typeof(KickApiModule).FullName ?? "" } + }; + } + + public ModuleManifest Manifest { get; } + + public void ConfigureServices(IServiceCollection services, ModuleHostContext context) + { + // built-in:无需额外注入 + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) + { + if (!endpoints.ServiceProvider.GetRequiredService().GetValue("ExternalApi:Enabled", true)) + { + // 预留开关;默认开启 + } + + endpoints.MapKickApi(); + } + + public IEnumerable GetApis(ModuleHostContext context) + { + yield return new ModuleApiTypeDefinition + { + Type = ExternalApiTypes.Kick, + DisplayName = "踢人/封禁", + Route = "/api/kick", + Description = "从配置的 Bot 管理的频道/群组中踢出或封禁指定用户(按 X-API-Key 匹配配置项)。", + Order = 10 + }; + } +} diff --git a/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs new file mode 100644 index 0000000..8e87c64 --- /dev/null +++ b/src/TelegramPanel.Web/Modules/BuiltIn/TaskCatalogModule.cs @@ -0,0 +1,78 @@ +using TelegramPanel.Core.BatchTasks; +using TelegramPanel.Modules; +using MudBlazor; + +namespace TelegramPanel.Web.Modules.BuiltIn; + +public sealed class TaskCatalogModule : ITelegramPanelModule, IModuleTaskProvider +{ + public TaskCatalogModule(string version) + { + Manifest = new ModuleManifest + { + Id = "builtin.tasks", + Name = "任务:内置批量任务", + Version = version, + Host = new HostCompatibility(), + Entry = new ModuleEntryPoint { Assembly = "", Type = typeof(TaskCatalogModule).FullName ?? "" } + }; + } + + public ModuleManifest Manifest { get; } + + public void ConfigureServices(IServiceCollection services, ModuleHostContext context) + { + // 内置任务的执行由宿主 BatchTaskBackgroundService 负责;这里只提供“元数据”用于 UI 展示与创建。 + } + + public void MapEndpoints(IEndpointRouteBuilder endpoints, ModuleHostContext context) + { + // 无 endpoints + } + + public IEnumerable GetTasks(ModuleHostContext context) + { + yield return new ModuleTaskDefinition + { + Category = "bot", + TaskType = BatchTaskTypes.Invite, + DisplayName = "批量邀请", + Description = "对所选频道批量邀请用户名列表(Bot/执行账号需具备权限)。", + Icon = Icons.Material.Filled.PersonAdd, + CreateRoute = "/batch/invite", + Order = 10 + }; + + yield return new ModuleTaskDefinition + { + Category = "bot", + TaskType = BatchTaskTypes.SetAdmin, + DisplayName = "设置管理员", + Description = "对所选频道批量设置管理员(Bot/执行账号需具备权限)。", + Icon = Icons.Material.Filled.AdminPanelSettings, + CreateRoute = "/batch/admins", + 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", + TaskType = BatchTaskTypes.ExternalApiKick, + DisplayName = "外部 API:踢人/封禁", + Description = "由外部接口触发并记录到任务中心(一般无需手动创建)。", + Icon = Icons.Material.Filled.Link, + Order = 1000 + }; + } +} diff --git a/src/TelegramPanel.Web/Modules/ModuleApplicationExtensions.cs b/src/TelegramPanel.Web/Modules/ModuleApplicationExtensions.cs new file mode 100644 index 0000000..d011d65 --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleApplicationExtensions.cs @@ -0,0 +1,13 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.DependencyInjection; + +namespace TelegramPanel.Web.Modules; + +public static class ModuleApplicationExtensions +{ + public static void MapInstalledModules(this WebApplication app) + { + var registry = app.Services.GetRequiredService(); + registry.MapEndpoints(app); + } +} diff --git a/src/TelegramPanel.Web/Modules/ModuleBootstrapper.cs b/src/TelegramPanel.Web/Modules/ModuleBootstrapper.cs new file mode 100644 index 0000000..f5f0c2c --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleBootstrapper.cs @@ -0,0 +1,243 @@ +using System.Reflection; +using System.Runtime.Loader; +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using TelegramPanel.Modules; +using TelegramPanel.Web.Modules.BuiltIn; + +namespace TelegramPanel.Web.Modules; + +public static class ModuleBootstrapper +{ + public static void AddModuleSystem(this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) + { + var hostVersion = ModulePaths.GetHostVersion(); + var root = ModulePaths.ResolveModulesRoot(configuration, environment); + var layout = ModulePaths.GetLayout(root); + var builtInCatalog = new BuiltInModuleCatalog(hostVersion); + + services.AddSingleton(layout); + services.AddSingleton(new ModuleHostContext(hostVersion, layout.Root)); + services.AddSingleton(); + services.AddSingleton(builtInCatalog); + services.AddSingleton(sp => new ModuleInstallerService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + hostVersion)); + + // 在 DI 构建前加载模块清单并执行 ConfigureServices + var registry = new ModuleRegistry(); + services.AddSingleton(registry); + + using var loggerFactory = LoggerFactory.Create(b => b.AddConsole()); + var logger = loggerFactory.CreateLogger("ModuleBootstrapper"); + var stateStore = new ModuleStateStore(layout); + + var state = stateStore.LoadAsync().GetAwaiter().GetResult(); + EnsureBuiltInModules(state, builtInCatalog, hostVersion); + stateStore.SaveAsync(state).GetAwaiter().GetResult(); + + var context = new ModuleHostContext(hostVersion, layout.Root); + + // 1) 内置模块 + foreach (var builtIn in builtInCatalog.CreateModules()) + { + var item = state.Modules.FirstOrDefault(m => string.Equals(m.Id, builtIn.Manifest.Id, StringComparison.Ordinal)); + var enabled = item?.Enabled ?? true; + if (!enabled) + continue; + + TryConfigureAndRegister(logger, registry, services, builtIn.Manifest.Id, builtIn.Manifest.Version, builtIn, context, builtIn.Manifest, builtIn: true, moduleRootPath: null); + } + + // 2) 外部安装模块(仅加载启用且有 ActiveVersion 的) + foreach (var item in state.Modules.Where(m => !m.BuiltIn && m.Enabled && !string.IsNullOrWhiteSpace(m.ActiveVersion)).ToList()) + { + var id = item.Id; + var version = item.ActiveVersion!.Trim(); + var moduleRoot = Path.Combine(layout.InstalledDir, id, version); + var manifestPath = Path.Combine(moduleRoot, "manifest.json"); + + if (!File.Exists(manifestPath)) + { + logger.LogWarning("Module manifest missing: {Id} {Version}", id, version); + item.Enabled = false; + continue; + } + + ModuleManifest? manifest; + try + { + var json = File.ReadAllText(manifestPath); + manifest = JsonSerializer.Deserialize(json); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to parse module manifest: {Id} {Version}", id, version); + item.Enabled = false; + continue; + } + + if (manifest == null) + { + item.Enabled = false; + continue; + } + + NormalizeManifest(manifest); + + try + { + var module = LoadExternalModule(moduleRoot, manifest); + TryConfigureAndRegister(logger, registry, services, id, version, module, context, manifest, builtIn: false, moduleRootPath: moduleRoot); + + item.LastGoodVersion = version; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Module load failed: {Id} {Version}", id, version); + + // 自动回滚:尝试切回 lastGood + var fallback = (item.LastGoodVersion ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(fallback) && !string.Equals(fallback, version, StringComparison.Ordinal)) + { + logger.LogWarning("Module rollback to last good: {Id} {Version}", id, fallback); + item.ActiveVersion = fallback; + + try + { + var fallbackRoot = Path.Combine(layout.InstalledDir, id, fallback); + var fallbackManifestPath = Path.Combine(fallbackRoot, "manifest.json"); + if (File.Exists(fallbackManifestPath)) + { + var json = File.ReadAllText(fallbackManifestPath); + var fallbackManifest = JsonSerializer.Deserialize(json); + if (fallbackManifest != null) + { + NormalizeManifest(fallbackManifest); + var fallbackModule = LoadExternalModule(fallbackRoot, fallbackManifest); + TryConfigureAndRegister(logger, registry, services, id, fallback, fallbackModule, context, fallbackManifest, builtIn: false, moduleRootPath: fallbackRoot); + } + } + } + catch (Exception ex2) + { + logger.LogWarning(ex2, "Module rollback load failed: {Id} {Version}", id, fallback); + item.Enabled = false; + } + } + else + { + item.Enabled = false; + } + } + } + + stateStore.SaveAsync(state).GetAwaiter().GetResult(); + + // 贡献注册表(任务/API/UI 元数据):启动时固化一次,启用/停用通常需要重启 + services.AddSingleton(sp => new ModuleContributionRegistry( + sp.GetRequiredService(), + sp.GetRequiredService>())); + } + + private static void TryConfigureAndRegister( + ILogger logger, + ModuleRegistry registry, + IServiceCollection services, + string id, + string version, + ITelegramPanelModule module, + ModuleHostContext context, + ModuleManifest manifest, + bool builtIn, + string? moduleRootPath) + { + try + { + module.ConfigureServices(services, context); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Module ConfigureServices failed: {Id} {Version}", id, version); + throw; + } + + registry.Add(new LoadedModule(id, version, builtIn, module, context, manifest, moduleRootPath)); + } + + private static void EnsureBuiltInModules(ModuleState state, BuiltInModuleCatalog builtInCatalog, string hostVersion) + { + state.Modules ??= new List(); + + foreach (var manifest in builtInCatalog.CreateModules().Select(m => m.Manifest)) + { + var id = manifest.Id; + var item = state.Modules.FirstOrDefault(m => string.Equals(m.Id, id, StringComparison.Ordinal)); + if (item == null) + { + item = new ModuleStateItem + { + Id = id, + BuiltIn = true, + Enabled = true, + ActiveVersion = hostVersion, + LastGoodVersion = hostVersion, + InstalledVersions = new List { hostVersion } + }; + state.Modules.Add(item); + continue; + } + + item.BuiltIn = true; + item.ActiveVersion = hostVersion; + item.LastGoodVersion ??= hostVersion; + item.InstalledVersions ??= new List(); + if (!item.InstalledVersions.Contains(hostVersion, StringComparer.Ordinal)) + item.InstalledVersions.Add(hostVersion); + } + } + + private static ITelegramPanelModule LoadExternalModule(string moduleRoot, ModuleManifest manifest) + { + var libDir = Path.Combine(moduleRoot, "lib"); + var assemblyFile = Path.Combine(libDir, manifest.Entry.Assembly); + if (!File.Exists(assemblyFile)) + throw new FileNotFoundException($"入口程序集不存在:{assemblyFile}"); + + var alc = new ModuleLoadContext(assemblyFile); + var asm = alc.LoadFromAssemblyPath(assemblyFile); + var typeName = manifest.Entry.Type; + var t = asm.GetType(typeName, throwOnError: true, ignoreCase: false); + if (t == null) + throw new TypeLoadException($"入口类型不存在:{typeName}"); + + if (!typeof(ITelegramPanelModule).IsAssignableFrom(t)) + throw new InvalidOperationException($"入口类型未实现 ITelegramPanelModule:{typeName}"); + + return (ITelegramPanelModule)Activator.CreateInstance(t)!; + } + + private static void NormalizeManifest(ModuleManifest manifest) + { + manifest.Id = (manifest.Id ?? "").Trim(); + manifest.Name = (manifest.Name ?? "").Trim(); + manifest.Version = (manifest.Version ?? "").Trim(); + manifest.Entry ??= new ModuleEntryPoint(); + manifest.Entry.Assembly = (manifest.Entry.Assembly ?? "").Trim(); + manifest.Entry.Type = (manifest.Entry.Type ?? "").Trim(); + manifest.Host ??= new HostCompatibility(); + manifest.Host.Min = (manifest.Host.Min ?? "").Trim(); + manifest.Host.Max = (manifest.Host.Max ?? "").Trim(); + manifest.Dependencies ??= new List(); + foreach (var d in manifest.Dependencies) + { + d.Id = (d.Id ?? "").Trim(); + d.Range = (d.Range ?? "").Trim(); + } + } +} diff --git a/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs b/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs new file mode 100644 index 0000000..5bddacd --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleContributionRegistry.cs @@ -0,0 +1,190 @@ +using Microsoft.Extensions.Logging; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Modules; + +public sealed class ModuleContributionRegistry +{ + public ModuleContributionRegistry(ModuleRegistry registry, ILogger logger) + { + var tasks = new List(); + var apis = new List(); + var pages = new List(); + var navs = new List(); + var diagnostics = new List(); + + foreach (var m in registry.Modules) + { + if (m.Instance is IModuleTaskProvider taskProvider) + { + foreach (var t in taskProvider.GetTasks(m.Context) ?? Array.Empty()) + { + tasks.Add(new RegisteredTaskDefinition(m, NormalizeTask(t))); + } + } + + if (m.Instance is IModuleApiProvider apiProvider) + { + foreach (var a in apiProvider.GetApis(m.Context) ?? Array.Empty()) + { + apis.Add(new RegisteredApiTypeDefinition(m, NormalizeApi(a))); + } + } + + if (m.Instance is IModuleUiProvider uiProvider) + { + foreach (var p in uiProvider.GetPages(m.Context) ?? Array.Empty()) + { + pages.Add(new RegisteredPageDefinition(m, NormalizePage(p))); + } + foreach (var n in uiProvider.GetNavItems(m.Context) ?? Array.Empty()) + { + navs.Add(new RegisteredNavItem(m, NormalizeNav(n))); + } + } + } + + Tasks = tasks; + ApiTypes = apis; + Pages = pages; + NavItems = navs; + + TaskTypeToDefinition = BuildTaskIndex(tasks, diagnostics, logger); + ApiTypeToDefinition = BuildApiIndex(apis, diagnostics, logger); + PageKeyToDefinition = BuildPageIndex(pages, diagnostics, logger); + Diagnostics = diagnostics; + } + + public IReadOnlyList Tasks { get; } + public IReadOnlyList ApiTypes { get; } + public IReadOnlyList Pages { get; } + public IReadOnlyList NavItems { get; } + + public IReadOnlyDictionary TaskTypeToDefinition { get; } + public IReadOnlyDictionary ApiTypeToDefinition { get; } + public IReadOnlyDictionary<(string moduleId, string pageKey), RegisteredPageDefinition> PageKeyToDefinition { get; } + + public IReadOnlyList Diagnostics { get; } + + private static IReadOnlyDictionary BuildTaskIndex( + List list, + List diagnostics, + ILogger logger) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var t in list) + { + var key = (t.Definition.TaskType ?? "").Trim(); + if (key.Length == 0) + continue; + + if (dict.ContainsKey(key)) + { + var msg = $"任务类型冲突:{key} 同时来自 {dict[key].Module.Id} 与 {t.Module.Id},已忽略后者"; + diagnostics.Add(msg); + logger.LogWarning(msg); + continue; + } + + dict[key] = t; + } + return dict; + } + + private static IReadOnlyDictionary BuildApiIndex( + List list, + List diagnostics, + ILogger logger) + { + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var a in list) + { + var key = (a.Definition.Type ?? "").Trim(); + if (key.Length == 0) + continue; + if (dict.ContainsKey(key)) + { + var msg = $"API 类型冲突:{key} 同时来自 {dict[key].Module.Id} 与 {a.Module.Id},已忽略后者"; + diagnostics.Add(msg); + logger.LogWarning(msg); + continue; + } + dict[key] = a; + } + return dict; + } + + private static IReadOnlyDictionary<(string moduleId, string pageKey), RegisteredPageDefinition> BuildPageIndex( + List list, + List diagnostics, + ILogger logger) + { + var dict = new Dictionary<(string moduleId, string pageKey), RegisteredPageDefinition>(); + foreach (var p in list) + { + var moduleId = p.Module.Id; + var key = (p.Definition.Key ?? "").Trim(); + if (key.Length == 0) + continue; + var k = (moduleId, key); + if (dict.ContainsKey(k)) + { + var msg = $"模块页面 Key 冲突:{moduleId}/{key} 重复定义,已保留第一个"; + diagnostics.Add(msg); + logger.LogWarning(msg); + continue; + } + dict[k] = p; + } + return dict; + } + + private static ModuleTaskDefinition NormalizeTask(ModuleTaskDefinition t) + { + t.Category = (t.Category ?? "").Trim(); + t.TaskType = (t.TaskType ?? "").Trim(); + t.DisplayName = (t.DisplayName ?? "").Trim(); + t.Description = (t.Description ?? "").Trim(); + t.Icon = (t.Icon ?? "").Trim(); + t.CreateRoute = (t.CreateRoute ?? "").Trim(); + if (string.IsNullOrWhiteSpace(t.CreateRoute)) t.CreateRoute = null; + t.EditorComponentType = (t.EditorComponentType ?? "").Trim(); + if (string.IsNullOrWhiteSpace(t.EditorComponentType)) t.EditorComponentType = null; + return t; + } + + private static ModuleApiTypeDefinition NormalizeApi(ModuleApiTypeDefinition a) + { + a.Type = (a.Type ?? "").Trim(); + a.DisplayName = (a.DisplayName ?? "").Trim(); + a.Route = (a.Route ?? "").Trim(); + a.Description = (a.Description ?? "").Trim(); + return a; + } + + private static ModulePageDefinition NormalizePage(ModulePageDefinition p) + { + p.Key = (p.Key ?? "").Trim(); + p.Title = (p.Title ?? "").Trim(); + p.Icon = (p.Icon ?? "").Trim(); + p.ComponentType = (p.ComponentType ?? "").Trim(); + p.Group = (p.Group ?? "").Trim(); + if (string.IsNullOrWhiteSpace(p.Group)) p.Group = null; + return p; + } + + private static ModuleNavItem NormalizeNav(ModuleNavItem n) + { + n.Title = (n.Title ?? "").Trim(); + n.Href = (n.Href ?? "").Trim(); + n.Icon = (n.Icon ?? "").Trim(); + n.Group = (n.Group ?? "").Trim(); + if (string.IsNullOrWhiteSpace(n.Group)) n.Group = null; + return n; + } +} + +public sealed record RegisteredTaskDefinition(LoadedModule Module, ModuleTaskDefinition Definition); +public sealed record RegisteredApiTypeDefinition(LoadedModule Module, ModuleApiTypeDefinition Definition); +public sealed record RegisteredPageDefinition(LoadedModule Module, ModulePageDefinition Definition); +public sealed record RegisteredNavItem(LoadedModule Module, ModuleNavItem Definition); diff --git a/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs b/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs new file mode 100644 index 0000000..2e1c3e4 --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleInstallerService.cs @@ -0,0 +1,504 @@ +using System.IO.Compression; +using System.Text.Json; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Modules; + +public sealed class ModuleInstallerService +{ + private readonly ModuleLayout _layout; + private readonly ModuleStateStore _stateStore; + private readonly BuiltIn.BuiltInModuleCatalog _builtInCatalog; + private readonly string _hostVersion; + + public ModuleInstallerService(ModuleLayout layout, ModuleStateStore stateStore, BuiltIn.BuiltInModuleCatalog builtInCatalog, string hostVersion) + { + _layout = layout; + _stateStore = stateStore; + _builtInCatalog = builtInCatalog; + _hostVersion = hostVersion; + } + + public async Task> GetOverviewAsync() + { + var state = await _stateStore.LoadAsync(); + var list = new List(); + + foreach (var item in state.Modules.OrderBy(x => x.Id, StringComparer.OrdinalIgnoreCase)) + { + var active = (item.ActiveVersion ?? "").Trim(); + ModuleManifest? manifest = null; + string? manifestError = null; + + if (!string.IsNullOrWhiteSpace(active)) + { + try + { + if (item.BuiltIn) + { + if (_builtInCatalog.TryGetManifest(item.Id, out var m)) + manifest = m; + else + manifestError = "内置模块未注册"; + } + else + { + var manifestPath = Path.Combine(_layout.InstalledDir, item.Id, active, "manifest.json"); + if (File.Exists(manifestPath)) + { + var json = await File.ReadAllTextAsync(manifestPath); + manifest = JsonSerializer.Deserialize(json); + } + } + } + catch (Exception ex) + { + manifestError = ex.Message; + } + } + + list.Add(new ModuleOverview( + Id: item.Id, + Enabled: item.Enabled, + ActiveVersion: item.ActiveVersion, + LastGoodVersion: item.LastGoodVersion, + InstalledVersions: item.InstalledVersions.OrderByDescending(x => x, StringComparer.Ordinal).ToList(), + Manifest: manifest, + ManifestError: manifestError, + BuiltIn: item.BuiltIn)); + } + + return list; + } + + public async Task InstallAsync(Stream packageStream, string fileName, bool enableAfterInstall = false) + { + if (packageStream == null) + throw new ArgumentNullException(nameof(packageStream)); + + fileName = (fileName ?? "").Trim(); + if (fileName.Length == 0) + fileName = "module.tpm"; + + var installId = Guid.NewGuid().ToString("N"); + var staging = Path.Combine(_layout.StagingDir, installId); + Directory.CreateDirectory(staging); + + try + { + using var buffer = new MemoryStream(); + await packageStream.CopyToAsync(buffer); + buffer.Position = 0; + + // 1) 解压到 staging + using (var zip = new ZipArchive(buffer, ZipArchiveMode.Read, leaveOpen: true)) + { + var extractError = ExtractZipToDirectorySafe(zip, staging); + if (!string.IsNullOrWhiteSpace(extractError)) + return InstallResult.Fail(extractError); + } + + // 2) 读取 manifest + var manifestPath = Path.Combine(staging, "manifest.json"); + if (!File.Exists(manifestPath)) + return InstallResult.Fail("缺少 manifest.json"); + + var manifestJson = await File.ReadAllTextAsync(manifestPath); + var manifest = JsonSerializer.Deserialize(manifestJson); + if (manifest == null) + return InstallResult.Fail("manifest.json 格式无效"); + + NormalizeManifest(manifest); + var validateError = ValidateManifest(manifest); + if (!string.IsNullOrWhiteSpace(validateError)) + return InstallResult.Fail(validateError); + + var hostCompatError = CheckHostCompatibility(manifest); + if (!string.IsNullOrWhiteSpace(hostCompatError)) + return InstallResult.Fail(hostCompatError); + + // 3) 基础结构校验(entry assembly) + var entryAssembly = (manifest.Entry?.Assembly ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(entryAssembly)) + { + var entryPath = Path.Combine(staging, "lib", entryAssembly); + if (!File.Exists(entryPath)) + return InstallResult.Fail($"入口程序集不存在:lib/{entryAssembly}"); + } + + // 4) 写入 packages//.tpm(便于回滚/留档) + var packageDir = Path.Combine(_layout.PackagesDir, manifest.Id); + Directory.CreateDirectory(packageDir); + var packagePath = Path.Combine(packageDir, $"{manifest.Version}.tpm"); + if (!File.Exists(packagePath)) + await File.WriteAllBytesAsync(packagePath, buffer.ToArray()); + + // 5) 安装目录:installed// + var targetDir = Path.Combine(_layout.InstalledDir, manifest.Id, manifest.Version); + if (Directory.Exists(targetDir)) + return InstallResult.Fail("该版本已安装"); + + Directory.CreateDirectory(Path.Combine(_layout.InstalledDir, manifest.Id)); + Directory.Move(staging, targetDir); + + // 6) 更新 state + var state = await _stateStore.LoadAsync(); + var item = state.Modules.FirstOrDefault(m => string.Equals(m.Id, manifest.Id, StringComparison.Ordinal)); + if (item == null) + { + item = new ModuleStateItem { Id = manifest.Id, Enabled = false, BuiltIn = false }; + state.Modules.Add(item); + } + + item.InstalledVersions ??= new List(); + if (!item.InstalledVersions.Contains(manifest.Version, StringComparer.Ordinal)) + item.InstalledVersions.Add(manifest.Version); + + item.ActiveVersion ??= manifest.Version; + if (enableAfterInstall) + item.Enabled = true; + + await _stateStore.SaveAsync(state); + + return InstallResult.Ok(manifest.Id, manifest.Version); + } + catch (Exception ex) + { + return InstallResult.Fail(ex.Message); + } + finally + { + // 如果 staging 还在(未 move),清理 + try + { + if (Directory.Exists(staging)) + Directory.Delete(staging, recursive: true); + } + catch + { + // ignore + } + } + } + + public async Task EnableAsync(string id, string? version = null) + { + id = (id ?? "").Trim(); + version = string.IsNullOrWhiteSpace(version) ? null : version.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) + { + // 内置模块版本跟随宿主,不从 installed///manifest.json 读取 + item.ActiveVersion = _hostVersion; + item.InstalledVersions ??= new List(); + if (!item.InstalledVersions.Contains(_hostVersion, StringComparer.Ordinal)) + item.InstalledVersions.Add(_hostVersion); + + if (!_builtInCatalog.TryGetManifest(id, out var builtInManifest)) + return OperationResult.Fail("内置模块未注册"); + + var builtInHostCompatError = CheckHostCompatibility(builtInManifest); + if (!string.IsNullOrWhiteSpace(builtInHostCompatError)) + return OperationResult.Fail(builtInHostCompatError); + + var builtInDepError = await CheckDependenciesAsync(state, builtInManifest); + if (!string.IsNullOrWhiteSpace(builtInDepError)) + return OperationResult.Fail(builtInDepError); + + item.Enabled = true; + await _stateStore.SaveAsync(state); + return OperationResult.Ok(); + } + + if (!string.IsNullOrWhiteSpace(version)) + item.ActiveVersion = version; + + if (string.IsNullOrWhiteSpace(item.ActiveVersion)) + return OperationResult.Fail("未选择启用的版本"); + + var manifest = await TryLoadManifestAsync(id, item.ActiveVersion); + if (manifest == null) + return OperationResult.Fail("无法读取 manifest.json"); + + var hostCompatError = CheckHostCompatibility(manifest); + if (!string.IsNullOrWhiteSpace(hostCompatError)) + return OperationResult.Fail(hostCompatError); + + var depError = await CheckDependenciesAsync(state, manifest); + if (!string.IsNullOrWhiteSpace(depError)) + return OperationResult.Fail(depError); + + item.Enabled = true; + await _stateStore.SaveAsync(state); + return OperationResult.Ok(); + } + + public async Task DisableAsync(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("模块不存在"); + + item.Enabled = false; + await _stateStore.SaveAsync(state); + return OperationResult.Ok(); + } + + public async Task SetActiveVersionAsync(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("内置模块版本随宿主,不支持切换"); + + var dir = Path.Combine(_layout.InstalledDir, id, version); + if (!Directory.Exists(dir)) + return OperationResult.Fail("该版本未安装"); + + item.ActiveVersion = version; + await _stateStore.SaveAsync(state); + return OperationResult.Ok(); + } + + public async Task RemoveModuleAsync(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.Enabled = false; + await _stateStore.SaveAsync(state); + + var moduleDir = Path.Combine(_layout.InstalledDir, id); + if (Directory.Exists(moduleDir)) + { + var trash = Path.Combine(_layout.TrashDir, $"{id}-{DateTime.UtcNow:yyyyMMddHHmmss}"); + Directory.Move(moduleDir, trash); + } + + 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(); + } + + private async Task TryLoadManifestAsync(string id, string version) + { + try + { + var path = Path.Combine(_layout.InstalledDir, id, version, "manifest.json"); + if (!File.Exists(path)) + return null; + var json = await File.ReadAllTextAsync(path); + var manifest = JsonSerializer.Deserialize(json); + if (manifest == null) + return null; + NormalizeManifest(manifest); + return manifest; + } + catch + { + return null; + } + } + + private async Task CheckDependenciesAsync(ModuleState state, ModuleManifest manifest) + { + foreach (var dep in manifest.Dependencies ?? new List()) + { + var depId = (dep.Id ?? "").Trim(); + if (depId.Length == 0) + return "依赖项缺少 id"; + + var found = state.Modules.FirstOrDefault(m => string.Equals(m.Id, depId, StringComparison.Ordinal)); + if (found == null || string.IsNullOrWhiteSpace(found.ActiveVersion)) + return $"缺少依赖:{depId}"; + + if (!found.Enabled) + return $"依赖未启用:{depId}"; + + if (!SemVer.TryParse(found.ActiveVersion, out var installed)) + return $"依赖版本无效:{depId} {found.ActiveVersion}"; + + var rangeExpr = (dep.Range ?? "").Trim(); + if (!VersionRange.TryParse(rangeExpr, out var range, out var err)) + return $"依赖 range 无效:{depId} ({err})"; + + if (!range.Contains(installed)) + return $"依赖不满足:{depId} 需要 {rangeExpr},当前 {found.ActiveVersion}"; + } + + return null; + } + + private string? CheckHostCompatibility(ModuleManifest manifest) + { + if (!SemVer.TryParse(_hostVersion, out var host)) + return null; + + var minOk = true; + var maxOk = true; + + if (SemVer.TryParse(manifest.Host?.Min, out var min)) + minOk = host.CompareTo(min) >= 0; + if (SemVer.TryParse(manifest.Host?.Max, out var max)) + maxOk = host.CompareTo(max) <= 0; + + if (!minOk || !maxOk) + return $"宿主版本不兼容:当前 {_hostVersion},要求 {manifest.Host?.Min ?? "-"} ~ {manifest.Host?.Max ?? "-"}"; + + return null; + } + + private static void NormalizeManifest(ModuleManifest manifest) + { + manifest.Id = (manifest.Id ?? "").Trim(); + manifest.Name = (manifest.Name ?? "").Trim(); + manifest.Version = (manifest.Version ?? "").Trim(); + manifest.Entry ??= new ModuleEntryPoint(); + manifest.Entry.Assembly = (manifest.Entry.Assembly ?? "").Trim(); + manifest.Entry.Type = (manifest.Entry.Type ?? "").Trim(); + manifest.Dependencies ??= new List(); + manifest.Host ??= new HostCompatibility(); + manifest.Host.Min = (manifest.Host.Min ?? "").Trim(); + manifest.Host.Max = (manifest.Host.Max ?? "").Trim(); + foreach (var d in manifest.Dependencies) + { + d.Id = (d.Id ?? "").Trim(); + d.Range = (d.Range ?? "").Trim(); + } + } + + private static string? ValidateManifest(ModuleManifest manifest) + { + if (string.IsNullOrWhiteSpace(manifest.Id)) + return "模块 id 不能为空"; + if (manifest.Id.Length > 100) + return "模块 id 过长"; + if (!IsSafeId(manifest.Id)) + return "模块 id 仅允许字母/数字/.-_"; + + if (string.IsNullOrWhiteSpace(manifest.Name)) + return "模块名称不能为空"; + if (manifest.Name.Length > 100) + return "模块名称过长"; + + if (!SemVer.TryParse(manifest.Version, out _)) + return "模块版本必须是 x.y.z"; + + if (string.IsNullOrWhiteSpace(manifest.Entry.Assembly) || string.IsNullOrWhiteSpace(manifest.Entry.Type)) + return "入口点缺失(entry.assembly / entry.type)"; + + return null; + } + + private static bool IsSafeId(string id) + { + foreach (var ch in id) + { + if (char.IsLetterOrDigit(ch)) + continue; + if (ch is '.' or '-' or '_' ) + continue; + return false; + } + + return true; + } + + private static string? ExtractZipToDirectorySafe(ZipArchive zip, string destinationDir) + { + if (zip == null) + throw new ArgumentNullException(nameof(zip)); + if (string.IsNullOrWhiteSpace(destinationDir)) + throw new ArgumentException("destinationDir 不能为空", nameof(destinationDir)); + + var destFull = Path.GetFullPath(destinationDir); + if (!destFull.EndsWith(Path.DirectorySeparatorChar)) + destFull += Path.DirectorySeparatorChar; + + foreach (var entry in zip.Entries) + { + var entryPath = (entry.FullName ?? string.Empty).Replace('\\', '/'); + if (entryPath.Length == 0) + continue; + + // 禁止绝对路径 + if (entryPath.StartsWith("/", StringComparison.Ordinal) || entryPath.StartsWith("\\", StringComparison.Ordinal)) + return "压缩包包含非法路径(绝对路径)"; + + var relative = entryPath.Replace('/', Path.DirectorySeparatorChar); + var targetFull = Path.GetFullPath(Path.Combine(destFull, relative)); + if (!targetFull.StartsWith(destFull, StringComparison.OrdinalIgnoreCase)) + return "压缩包包含非法路径(路径穿越)"; + + // 目录条目(通常以 / 结尾,或 Name 为空) + if (entryPath.EndsWith("/", StringComparison.Ordinal) || string.IsNullOrWhiteSpace(entry.Name)) + { + Directory.CreateDirectory(targetFull); + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetFull) ?? destFull); + entry.ExtractToFile(targetFull, overwrite: true); + } + + return null; + } +} + +public sealed record ModuleOverview( + string Id, + bool Enabled, + string? ActiveVersion, + string? LastGoodVersion, + IReadOnlyList InstalledVersions, + ModuleManifest? Manifest, + string? ManifestError, + bool BuiltIn); + +public sealed record InstallResult(bool Success, string Message, string? ModuleId = null, string? Version = null) +{ + public static InstallResult Ok(string id, string version) => new(true, "ok", id, version); + public static InstallResult Fail(string msg) => new(false, msg); +} + +public sealed record OperationResult(bool Success, string Message) +{ + public static OperationResult Ok() => new(true, "ok"); + public static OperationResult Fail(string msg) => new(false, msg); +} diff --git a/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs b/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs new file mode 100644 index 0000000..7195d5d --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleLoadContext.cs @@ -0,0 +1,46 @@ +using System.Reflection; +using System.Runtime.Loader; + +namespace TelegramPanel.Web.Modules; + +public sealed class ModuleLoadContext : AssemblyLoadContext +{ + private readonly AssemblyDependencyResolver _resolver; + private readonly string _baseDir; + + public ModuleLoadContext(string mainAssemblyPath) : base(isCollectible: false) + { + if (string.IsNullOrWhiteSpace(mainAssemblyPath)) + throw new ArgumentException("mainAssemblyPath 不能为空", nameof(mainAssemblyPath)); + + _resolver = new AssemblyDependencyResolver(mainAssemblyPath); + _baseDir = Path.GetDirectoryName(mainAssemblyPath) ?? ""; + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + 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; + + var candidate = Path.Combine(_baseDir, name + ".dll"); + if (File.Exists(candidate)) + return LoadFromAssemblyPath(candidate); + + return null; + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) + return LoadUnmanagedDllFromPath(path); + + return base.LoadUnmanagedDll(unmanagedDllName); + } +} diff --git a/src/TelegramPanel.Web/Modules/ModulePaths.cs b/src/TelegramPanel.Web/Modules/ModulePaths.cs new file mode 100644 index 0000000..62d1a56 --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModulePaths.cs @@ -0,0 +1,74 @@ +using System.Reflection; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Modules; + +public static class ModulePaths +{ + public static string GetHostVersion() + { + // 优先取 InformationalVersion(通常包含语义版本);取不到则退回 AssemblyVersion。 + var asm = Assembly.GetExecutingAssembly(); + var info = asm.GetCustomAttribute()?.InformationalVersion; + info = (info ?? "").Trim(); + if (SemVer.TryParse(NormalizeSemVer(info), out var v)) + return v.ToString(); + + var ver = asm.GetName().Version; + if (ver != null) + return $"{ver.Major}.{ver.Minor}.{(ver.Build < 0 ? 0 : ver.Build)}"; + + return "0.0.0"; + } + + public static string ResolveModulesRoot(IConfiguration configuration, IWebHostEnvironment environment) + { + var configured = (configuration["Modules:RootPath"] ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(configured)) + { + if (Path.IsPathRooted(configured)) + return configured; + + return Path.Combine(environment.ContentRootPath, configured); + } + + // Docker 默认把持久化目录挂到 /data + if (Directory.Exists("/data")) + return "/data/modules"; + + return Path.Combine(environment.ContentRootPath, "modules"); + } + + public static ModuleLayout GetLayout(string root) + { + root = root.Trim(); + return new ModuleLayout( + Root: root, + StateFile: Path.Combine(root, "state.json"), + PackagesDir: Path.Combine(root, "packages"), + InstalledDir: Path.Combine(root, "installed"), + ActiveDir: Path.Combine(root, "active"), + StagingDir: Path.Combine(root, "staging"), + TrashDir: Path.Combine(root, "trash")); + } + + private static string NormalizeSemVer(string value) + { + // InformationalVersion 可能是 "1.2.3+sha" 或 "1.2.3-rc.1" + var s = value; + var plus = s.IndexOf('+'); + if (plus >= 0) s = s.Substring(0, plus); + var dash = s.IndexOf('-'); + if (dash >= 0) s = s.Substring(0, dash); + return s.Trim(); + } +} + +public sealed record ModuleLayout( + string Root, + string StateFile, + string PackagesDir, + string InstalledDir, + string ActiveDir, + string StagingDir, + string TrashDir); diff --git a/src/TelegramPanel.Web/Modules/ModuleRegistry.cs b/src/TelegramPanel.Web/Modules/ModuleRegistry.cs new file mode 100644 index 0000000..9063beb --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleRegistry.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Routing; +using TelegramPanel.Modules; + +namespace TelegramPanel.Web.Modules; + +public sealed class ModuleRegistry +{ + private readonly List _modules = new(); + + public IReadOnlyList Modules => _modules; + + public void Add(LoadedModule module) => _modules.Add(module); + + public void MapEndpoints(IEndpointRouteBuilder endpoints) + { + foreach (var m in _modules) + { + try + { + m.Instance.MapEndpoints(endpoints, m.Context); + } + catch + { + // 模块出错不应影响主站启动;日志在上层打 + } + } + } +} + +public sealed record LoadedModule( + string Id, + string Version, + bool BuiltIn, + ITelegramPanelModule Instance, + ModuleHostContext Context, + ModuleManifest Manifest, + string? ModuleRootPath); diff --git a/src/TelegramPanel.Web/Modules/ModuleStateStore.cs b/src/TelegramPanel.Web/Modules/ModuleStateStore.cs new file mode 100644 index 0000000..5ec75ae --- /dev/null +++ b/src/TelegramPanel.Web/Modules/ModuleStateStore.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace TelegramPanel.Web.Modules; + +public sealed class ModuleStateStore +{ + private readonly ModuleLayout _layout; + private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + + public ModuleStateStore(ModuleLayout layout) + { + _layout = layout; + } + + public async Task LoadAsync() + { + EnsureDirectories(); + + if (!File.Exists(_layout.StateFile)) + return new ModuleState(); + + var json = await File.ReadAllTextAsync(_layout.StateFile); + if (string.IsNullOrWhiteSpace(json)) + return new ModuleState(); + + return JsonSerializer.Deserialize(json) ?? new ModuleState(); + } + + public async Task SaveAsync(ModuleState state) + { + EnsureDirectories(); + + state.SchemaVersion = 1; + state.Modules ??= new List(); + + var json = JsonSerializer.Serialize(state, _jsonOptions); + var target = _layout.StateFile; + var dir = Path.GetDirectoryName(target) ?? _layout.Root; + Directory.CreateDirectory(dir); + + var temp = target + ".tmp"; + await File.WriteAllTextAsync(temp, json, new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + if (File.Exists(target)) + File.Replace(temp, target, destinationBackupFileName: null); + else + File.Move(temp, target); + } + + private void EnsureDirectories() + { + Directory.CreateDirectory(_layout.Root); + Directory.CreateDirectory(_layout.PackagesDir); + Directory.CreateDirectory(_layout.InstalledDir); + Directory.CreateDirectory(_layout.ActiveDir); + Directory.CreateDirectory(_layout.StagingDir); + Directory.CreateDirectory(_layout.TrashDir); + } +} + +public sealed class ModuleState +{ + public int SchemaVersion { get; set; } = 1; + public List Modules { get; set; } = new(); +} + +public sealed class ModuleStateItem +{ + public string Id { get; set; } = ""; + public bool Enabled { get; set; } + public string? ActiveVersion { get; set; } + public string? LastGoodVersion { get; set; } + public List InstalledVersions { get; set; } = new(); + + public bool BuiltIn { get; set; } +} + diff --git a/src/TelegramPanel.Web/Program.cs b/src/TelegramPanel.Web/Program.cs index dde5847..1597c28 100644 --- a/src/TelegramPanel.Web/Program.cs +++ b/src/TelegramPanel.Web/Program.cs @@ -9,6 +9,7 @@ using TelegramPanel.Core; using TelegramPanel.Core.Services; using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Data; +using TelegramPanel.Web.Modules; using TelegramPanel.Web.Services; using Microsoft.Extensions.Logging; @@ -296,6 +297,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHttpClient(); +builder.Services.AddModuleSystem(builder.Configuration, builder.Environment); // 后台账号密码验证(Cookie 登录) builder.Services.Configure(builder.Configuration.GetSection("AdminAuth")); @@ -554,8 +556,8 @@ app.UseSerilogRequestLogging(); app.UseAuthentication(); app.UseAuthorization(); -// External API: /api/kick -TelegramPanel.Web.ExternalApi.KickApi.MapKickApi(app); +// Modules (built-in & installed): endpoints mapping +app.MapInstalledModules(); // 初始化后台登录凭据(首次启动会生成 admin_auth.json) var adminCredentials = app.Services.GetRequiredService(); diff --git a/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs b/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs index befa9d9..77da0e1 100644 --- a/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs +++ b/src/TelegramPanel.Web/Services/BatchTaskBackgroundService.cs @@ -7,6 +7,7 @@ using TelegramPanel.Core.Interfaces; using TelegramPanel.Core.Services; using TelegramPanel.Core.Services.Telegram; using TelegramPanel.Data.Entities; +using TelegramPanel.Modules; namespace TelegramPanel.Web.Services; @@ -102,11 +103,30 @@ public sealed class BatchTaskBackgroundService : BackgroundService (completed, failed) = await RunUserJoinSubscribeAsync(pending, taskManagement, accountTelegramTools, completed, failed, cancellationToken); break; default: - failed = pending.Total == 0 ? 1 : pending.Total; - await taskManagement.UpdateTaskProgressAsync(pending.Id, completed, failed); - await taskManagement.CompleteTaskAsync(pending.Id, success: false); - _logger.LogWarning("Unsupported batch task type: {TaskType} (task {TaskId})", pending.TaskType, pending.Id); - return; + // 模块扩展任务:从 DI 中查找对应 TaskType 的执行器 + var handler = scope.ServiceProvider + .GetServices() + .FirstOrDefault(h => string.Equals(h.TaskType, pending.TaskType, StringComparison.OrdinalIgnoreCase)); + + if (handler == null) + { + failed = pending.Total == 0 ? 1 : pending.Total; + await taskManagement.UpdateTaskProgressAsync(pending.Id, completed, failed); + await taskManagement.CompleteTaskAsync(pending.Id, success: false); + _logger.LogWarning("Unsupported batch task type: {TaskType} (task {TaskId})", pending.TaskType, pending.Id); + return; + } + + var host = new DbBackedModuleTaskExecutionHost(pending, taskManagement, scope.ServiceProvider); + await handler.ExecuteAsync(host, cancellationToken); + + var after = await taskManagement.GetTaskAsync(pending.Id); + if (after != null) + { + completed = after.Completed; + failed = after.Failed; + } + break; } // 如果任务被用户取消(当前实现:Cancel 会把状态写成 failed),则不覆盖它 @@ -310,4 +330,36 @@ public sealed class BatchTaskBackgroundService : BackgroundService List Links, int DelayMs ); + + private sealed class DbBackedModuleTaskExecutionHost : IModuleTaskExecutionHost + { + private readonly BatchTask _task; + private readonly BatchTaskManagementService _taskManagement; + + public DbBackedModuleTaskExecutionHost(BatchTask task, BatchTaskManagementService taskManagement, IServiceProvider services) + { + _task = task; + _taskManagement = taskManagement; + Services = services; + } + + public int TaskId => _task.Id; + public string TaskType => _task.TaskType; + public int Total => _task.Total; + public string? Config => _task.Config; + public IServiceProvider Services { get; } + + public async Task IsStillRunningAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + var latest = await _taskManagement.GetTaskAsync(_task.Id); + return latest != null && latest.Status == "running"; + } + + public async Task UpdateProgressAsync(int completed, int failed, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await _taskManagement.UpdateTaskProgressAsync(_task.Id, completed, failed); + } + } } diff --git a/src/TelegramPanel.Web/TelegramPanel.Web.csproj b/src/TelegramPanel.Web/TelegramPanel.Web.csproj index f57f9ab..3ec1ef2 100644 --- a/src/TelegramPanel.Web/TelegramPanel.Web.csproj +++ b/src/TelegramPanel.Web/TelegramPanel.Web.csproj @@ -20,6 +20,7 @@ +