第二阶段已完成:统一插件平台已从“内置功能开关”扩展为可安装、签名校验、版本激活和回滚的插件系统。
主要成果:
- 新增独立插件 SDK:[PluginContracts.cs (line 11)](E:/jiaowu/src/Jiaowu.Plugin.Abstractions/PluginContracts.cs:11)
- 支持 RSA-PSS/SHA-256 签名插件包、Host API 兼容性和 ZIP 安全检查:[PluginPackageValidator.cs (line 14)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageValidator.cs:14)
- 支持暂存、待激活、重启装载、失败记录、版本回滚:[PluginPackageService.cs (line 32)](E:/jiaowu/src/Jiaowu.Api/Infrastructure/Plugins/PluginPackageService.cs:32)
- 插件可注册控制器、服务、数据库迁移、后台任务和事件处理器
- 外部插件 API 统一使用 /api/plugin-extensions/{pluginId},并受启用状态中间件控制
- 插件中心新增 ZIP/SIG 上传、状态展示、激活、回滚和删除功能:[PluginsView.vue (line 212)](E:/jiaowu/web/src/views/PluginsView.vue:212)
- 提供可运行示例插件:[SamplePlugin.cs (line 10)](E:/jiaowu/samples/Jiaowu.SamplePlugin/SamplePlugin.cs:10)
- 提供打包签名脚本:[package-plugin.ps1](E:/jiaowu/scripts/package-plugin.ps1)
- 完整开发文档:[plugin-sdk.md (line 1)](E:/jiaowu/docs/plugin-sdk.md:1)
- 系统版本已调整为 3.0.0-beta1
This commit is contained in:
1 parent
7e3f138875
commit
05683e502a
35 files changed
+11247
-24
No files matched your search
@@ -9,7 +9,9 @@ namespace Jiaowu.Api.Controllers;
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/plugins")]
|
||||
public sealed class PluginsController(PluginRuntimeService runtime) : ControllerBase
|
||||
public sealed class PluginsController(
|
||||
PluginRuntimeService runtime,
|
||||
PluginPackageService packages) : ControllerBase
|
||||
{
|
||||
[HttpGet("runtime")]
|
||||
public async Task<ActionResult<IReadOnlyList<PluginRuntimeItem>>> GetRuntime(
|
||||
@@ -36,6 +38,103 @@ public sealed class PluginsController(PluginRuntimeService runtime) : Controller
|
||||
cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("packages")]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
public async Task<ActionResult<IReadOnlyList<PluginPackageReleaseItem>>> GetPackages(
|
||||
CancellationToken cancellationToken) =>
|
||||
Ok(await packages.ListAsync(cancellationToken));
|
||||
|
||||
[HttpPost("packages")]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
[Consumes("multipart/form-data")]
|
||||
[RequestSizeLimit(101 * 1024 * 1024)]
|
||||
public async Task<ActionResult<PluginPackageReleaseItem>> UploadPackage(
|
||||
[FromForm] PluginPackageUploadRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var package = await ReadAsync(request.Package, cancellationToken);
|
||||
var signature = await ReadAsync(request.Signature, cancellationToken);
|
||||
var result = await packages.StageAsync(
|
||||
package,
|
||||
signature,
|
||||
User.FindFirstValue(ClaimTypes.NameIdentifier),
|
||||
cancellationToken);
|
||||
return CreatedAtAction(nameof(GetPackages), new { id = result.Id }, result);
|
||||
}
|
||||
catch (PluginPackageConflictException exception)
|
||||
{
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "插件版本冲突",
|
||||
Detail = exception.Message,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
catch (PluginPackageException exception)
|
||||
{
|
||||
return ValidationProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("packages/{id:guid}/activate")]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
public async Task<ActionResult<PluginPackageReleaseItem>> ActivatePackage(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await packages.RequestActivationAsync(id, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (PluginPackageException exception)
|
||||
{
|
||||
return ValidationProblem(exception.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("packages/{id:guid}")]
|
||||
[Authorize(Roles = SystemRoles.SuperAdmin)]
|
||||
public async Task<IActionResult> DeletePackage(
|
||||
Guid id,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await packages.DeleteStagedAsync(id, cancellationToken)
|
||||
? NoContent()
|
||||
: NotFound();
|
||||
}
|
||||
catch (PluginPackageConflictException exception)
|
||||
{
|
||||
return Conflict(new ProblemDetails
|
||||
{
|
||||
Title = "插件版本不能删除",
|
||||
Detail = exception.Message,
|
||||
Status = StatusCodes.Status409Conflict
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<byte[]> ReadAsync(
|
||||
IFormFile file,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.Length <= 0) throw new PluginPackageException("上传文件不能为空。");
|
||||
await using var input = file.OpenReadStream();
|
||||
await using var buffer = new MemoryStream(checked((int)file.Length));
|
||||
await input.CopyToAsync(buffer, cancellationToken);
|
||||
return buffer.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record UpdatePluginStateRequest(bool IsEnabled);
|
||||
|
||||
public sealed class PluginPackageUploadRequest
|
||||
{
|
||||
public required IFormFile Package { get; init; }
|
||||
public required IFormFile Signature { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Jiaowu.Api.Domain.Common;
|
||||
|
||||
namespace Jiaowu.Api.Domain.System;
|
||||
|
||||
public enum PluginPackageStatus
|
||||
{
|
||||
Staged,
|
||||
PendingActivation,
|
||||
Active,
|
||||
Superseded,
|
||||
Failed
|
||||
}
|
||||
|
||||
public sealed class PluginPackageRelease : EntityBase
|
||||
{
|
||||
public required string PluginId { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required string Category { get; set; }
|
||||
public required string Version { get; set; }
|
||||
public required string PublisherId { get; set; }
|
||||
public required string HostApiVersion { get; set; }
|
||||
public required string Sha256 { get; set; }
|
||||
public required string ManifestJson { get; set; }
|
||||
public required byte[] PackageContent { get; set; }
|
||||
public required byte[] Signature { get; set; }
|
||||
public PluginPackageStatus Status { get; set; }
|
||||
public string? CreatedByUserId { get; set; }
|
||||
public DateTime? ActivationRequestedAt { get; set; }
|
||||
public string? FailureMessage { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PluginMigrationHistory : EntityBase
|
||||
{
|
||||
public required string PluginId { get; set; }
|
||||
public required string MigrationId { get; set; }
|
||||
public DateTime AppliedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -10,17 +10,33 @@ public sealed class PluginAvailabilityMiddleware(RequestDelegate next)
|
||||
PluginRuntimeService runtime)
|
||||
{
|
||||
var plugin = catalog.FindByApiPath(context.Request.Path);
|
||||
if (plugin is not null &&
|
||||
!await runtime.IsEnabledAsync(plugin.Id, context.RequestAborted))
|
||||
var externalPluginId = GetExternalPluginId(context.Request.Path);
|
||||
var pluginId = plugin?.Id ?? externalPluginId;
|
||||
if (pluginId is not null &&
|
||||
!await runtime.IsEnabledAsync(pluginId, context.RequestAborted))
|
||||
{
|
||||
await Results.Problem(
|
||||
statusCode: StatusCodes.Status404NotFound,
|
||||
title: "功能暂未开放",
|
||||
detail: $"“{plugin.Name}”插件当前已停用。")
|
||||
detail: plugin is null
|
||||
? "该扩展插件当前不可用。"
|
||||
: $"“{plugin.Name}”插件当前已停用。")
|
||||
.ExecuteAsync(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
}
|
||||
|
||||
private static string? GetExternalPluginId(PathString path)
|
||||
{
|
||||
const string prefix = "/api/plugin-extensions/";
|
||||
var value = path.Value;
|
||||
if (value is null || !value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
var remainder = value[prefix.Length..];
|
||||
var slash = remainder.IndexOf('/');
|
||||
var pluginId = slash < 0 ? remainder : remainder[..slash];
|
||||
return string.IsNullOrWhiteSpace(pluginId) ? null : pluginId;
|
||||
}
|
||||
}
|
||||
Loaded 3 of 35 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user