第二阶段已完成:统一插件平台已从“内置功能开关”扩展为可安装、签名校验、版本激活和回滚的插件系统。

主要成果:
- 新增独立插件 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:
biss committed 2026-10-01 10:22:46 +08:00
1 parent 7e3f138875
commit 05683e502a
35 files changed
+11247 -24

No files matched your search

+100 -1
View File
@@ -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