feat(modules): 模块系统支持任务/API/UI 扩展
- 新增模块抽象与 manifest/版本范围、安装/启用/停用/回滚\n- 模块管理页面:上传 .tpm/.zip、状态与冲突提示\n- 任务中心/新建任务改为基于模块贡献动态展示(system 不出现在新建列表)\n- API 管理改为基于模块贡献动态创建与通用配置编辑\n- 支持模块扩展任务执行器与扩展页面入口 /ext/{moduleId}/{pageKey}\n\n内置模块:builtin.kick-api、builtin.tasks
This commit is contained in:
1 parent
cfa53eb9b6
commit
09feeed2db
34 files changed
+3063
-129
No files matched your search
@@ -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<ModuleStateStore>();
|
||||
services.AddSingleton(builtInCatalog);
|
||||
services.AddSingleton(sp => new ModuleInstallerService(
|
||||
sp.GetRequiredService<ModuleLayout>(),
|
||||
sp.GetRequiredService<ModuleStateStore>(),
|
||||
sp.GetRequiredService<BuiltInModuleCatalog>(),
|
||||
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<ModuleManifest>(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<ModuleManifest>(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<ModuleRegistry>(),
|
||||
sp.GetRequiredService<ILogger<ModuleContributionRegistry>>()));
|
||||
}
|
||||
|
||||
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<ModuleStateItem>();
|
||||
|
||||
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<string> { hostVersion }
|
||||
};
|
||||
state.Modules.Add(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
item.BuiltIn = true;
|
||||
item.ActiveVersion = hostVersion;
|
||||
item.LastGoodVersion ??= hostVersion;
|
||||
item.InstalledVersions ??= new List<string>();
|
||||
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<ModuleDependency>();
|
||||
foreach (var d in manifest.Dependencies)
|
||||
{
|
||||
d.Id = (d.Id ?? "").Trim();
|
||||
d.Range = (d.Range ?? "").Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user