From 0ec4326813f5188f2c65e7c1a9888454c1a2762e Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 08:20:44 +0800 Subject: [PATCH 1/7] feat: add local SecAgent HTTP connector bootstrap --- SecRandom/App.axaml.cs | 4 + .../SecAgent/SecAgentHttpHostedService.cs | 294 ++++++++++++++++++ .../SecAgentPluginBootstrapHostedService.cs | 86 +++++ SecRandom/Views/FirstRunOobeWindow.axaml | 13 +- SecRandom/Views/FirstRunOobeWindow.axaml.cs | 5 - 5 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs create mode 100644 SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 331b155a..84ba11a5 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -57,6 +57,7 @@ using SecRandom.Services.Music; using SecRandom.Services.Settings; using SecRandom.Services.Security; +using SecRandom.Services.SecAgent; using SecRandom.Services.Telemetry; using SecRandom.Services.Verification; using SecRandom.Services.Voice; @@ -453,6 +454,9 @@ private void BuildHost() services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Local-only REST endpoint for the SecAgent connector. It intentionally has no UI/settings registration. + services.AddHostedService(); + services.AddHostedService(); services.AddSingleton(serviceProvider => new MusicLibraryService( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService>(), diff --git a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs new file mode 100644 index 00000000..63d5a21d --- /dev/null +++ b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SecRandom.Core.Abstraction.Services; +using SecRandom.Core.Enums; +using SecRandom.Core.Enums.Configs; +using SecRandom.Core.Models.Draw; +using SecRandom.Core.Services.Config; +using SecRandom.Core.Services.Draw; +using SecRandom.Services.Draw; +using SecRandom.Services.Linkage; +using SecRandom.Services.Notification; +using SecRandom.Services.Security; +using SecRandom.Shared.Extensions; +using SecRandom.Shared.Models.Profile; + +namespace SecRandom.Services.SecAgent; + +/// +/// Loopback-only REST endpoint for the local SecAgent connector. +/// SecRandom intentionally exposes ordinary HTTP/JSON here; tool discovery and hidden-tool +/// behavior belong to the SecAgent plugin. +/// +public sealed class SecAgentHttpHostedService( + ILogger logger, + IProfileService profileService, + MainConfigHandler configHandler, + IDrawTemporaryRecordService temporaryRecordService, + LinkageDrawCoordinator linkageDrawCoordinator, + NotificationService notificationService) : BackgroundService +{ + private const string Prefix = "http://127.0.0.1:3910/api/secagent/v1/"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpListener _listener = new(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _listener.Prefixes.Add(Prefix); + try + { + _listener.Start(); + logger.LogInformation("SecAgent loopback REST endpoint started at {Prefix}.", Prefix[..^1]); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start SecAgent loopback REST endpoint at {Prefix}.", Prefix); + return; + } + + try + { + while (!stoppingToken.IsCancellationRequested) + { + var context = await _listener.GetContextAsync().WaitAsync(stoppingToken).ConfigureAwait(false); + _ = Task.Run(() => HandleAsync(context, stoppingToken), CancellationToken.None); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (HttpListenerException) when (stoppingToken.IsCancellationRequested) + { + } + finally + { + _listener.Stop(); + _listener.Close(); + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + if (_listener.IsListening) + _listener.Stop(); + return base.StopAsync(cancellationToken); + } + + private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty; + var method = context.Request.HttpMethod.ToUpperInvariant(); + JsonNode result; + + if (method == "GET" && path == "/api/secagent/v1/students") + result = ListStudents(); + else if (method == "POST" && path == "/api/secagent/v1/students") + result = UpsertStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); + else if (method == "DELETE" && path == "/api/secagent/v1/students") + result = RemoveStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); + else if (method == "POST" && path == "/api/secagent/v1/draw/students") + result = await DrawStudentsAsync(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + else + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + result = new JsonObject { ["error"] = "Endpoint not found." }; + } + + await WriteJsonAsync(context.Response, result, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException ex) + { + await WriteErrorAsync(context.Response, HttpStatusCode.BadRequest, ex.Message).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + await WriteErrorAsync(context.Response, HttpStatusCode.Conflict, ex.Message).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "SecAgent REST request failed."); + await WriteErrorAsync(context.Response, HttpStatusCode.InternalServerError, "SecRandom request failed.").ConfigureAwait(false); + } + finally + { + context.Response.Close(); + } + } + + private JsonObject ListStudents() + { + var list = profileService.CurrentStudentList; + return new JsonObject + { + ["profile"] = list?.Name ?? string.Empty, + ["students"] = new JsonArray((list?.Students ?? []).Select(ToJson).ToArray()) + }; + } + + private JsonObject UpsertStudent(JsonObject arguments) + { + var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); + var recordId = ParseGuid(arguments["record_id"]?.GetValue()); + var id = StringArgument(arguments, "id"); + var student = recordId is not null ? list.Students.FirstOrDefault(item => item.RecordId == recordId) : null; + student ??= !string.IsNullOrWhiteSpace(id) ? list.Students.FirstOrDefault(item => item.Id == id) : null; + if (student is null) + { + student = new Student { RecordId = recordId ?? Guid.NewGuid() }; + list.Students.Add(student); + } + + student.Id = id; + student.Name = StringArgument(arguments, "name"); + student.Group = StringArgument(arguments, "group"); + student.Gender = StringArgument(arguments, "gender"); + student.Tags = StringArgument(arguments, "tags"); + student.Exists = arguments["exists"]?.GetValue() ?? true; + if (!student.IsCandidate) + throw new ArgumentException("Student requires a nonblank id or name."); + profileService.SaveProfile(); + return new JsonObject { ["student"] = ToJson(student), ["profile"] = list.Name }; + } + + private JsonObject RemoveStudent(JsonObject arguments) + { + var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); + var recordId = ParseGuid(arguments["record_id"]?.GetValue()); + var id = StringArgument(arguments, "id"); + var name = StringArgument(arguments, "name"); + var matches = list.Students.Where(item => + (recordId is not null && item.RecordId == recordId) + || (!string.IsNullOrWhiteSpace(id) && item.Id == id) + || (!string.IsNullOrWhiteSpace(name) && item.Name == name)).ToList(); + if (matches.Count != 1) + throw new InvalidOperationException(matches.Count == 0 ? "Student was not found." : "Student selector matched more than one student."); + list.Students.Remove(matches[0]); + profileService.SaveProfile(); + return new JsonObject { ["removed"] = ToJson(matches[0]), ["profile"] = list.Name }; + } + + private async Task DrawStudentsAsync(JsonObject arguments, CancellationToken cancellationToken) + { + var mode = StringArgument(arguments, "mode"); + if (mode is not ("flash" or "result_only")) + throw new ArgumentException("mode must be flash or result_only."); + + var requestedCount = Math.Clamp(arguments["count"]?.GetValue() ?? 1, 1, 100); + if (mode == "flash") requestedCount = 1; + var includeTags = StringArray(arguments, "include_tags"); + var excludeTags = StringArray(arguments, "exclude_tags"); + var includeIds = StringArray(arguments, "include_ids"); + var includeNames = StringArray(arguments, "include_names"); + var listName = profileService.CurrentStudentList?.Name ?? string.Empty; + var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, string.Empty, string.Empty); + + var result = await InvokeAuthorizedAsync(SecurityOperation.QuickDrawStart, () => + { + var engine = new DrawEngine(); + var draw = engine.DrawStudent(requestedCount, student => Matches(student, includeTags, excludeTags, includeIds, includeNames) + && !HasReachedTemporaryLimit(student, temporaryCounts), DrawSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName()); + if (!draw.IsSuccess || draw.Result.Count == 0) + return Task.FromResult(draw); + + profileService.RecordStudentHistory(draw.Result, DateTime.Now, requestedCount, + drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType, + courseName: linkageDrawCoordinator.GetCourseName()); + temporaryRecordService.RecordStudents(listName, string.Empty, string.Empty, draw.Result); + if (mode == "flash") + notificationService.QueueStudents(NotificationSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName(), draw.Result); + return Task.FromResult(draw); + }, cancellationToken).ConfigureAwait(false); + + return new JsonObject + { + ["mode"] = mode, + ["count"] = result.Result.Count, + ["status"] = result.Status.ToString(), + ["profile"] = listName, + ["students"] = new JsonArray(result.Result.Select(ToJson).ToArray()) + }; + } + + private async Task> InvokeAuthorizedAsync(SecurityOperation operation, Func>> action, CancellationToken cancellationToken) + { + DrawResult? result = null; + var authorized = await linkageDrawCoordinator.AuthorizeAsync(operation, + async () => result = await action().ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + return authorized && result is not null ? result : new DrawResult { Status = DrawStatus.Failure }; + } + + private bool HasReachedTemporaryLimit(Student student, IReadOnlyDictionary temporaryCounts) + { + var settings = configHandler.Data.QuickDrawSettings; + var threshold = settings.DrawMode switch + { + DrawMode.Repeat => 0, + DrawMode.NoRepeat => 1, + DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat), + _ => 1 + }; + return threshold > 0 && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold; + } + + private static bool Matches(Student student, IReadOnlyCollection includeTags, IReadOnlyCollection excludeTags, + IReadOnlyCollection includeIds, IReadOnlyCollection includeNames) + { + var tags = student.Tags.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return includeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && excludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && (includeIds.Count == 0 || includeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase)) + && (includeNames.Count == 0 || includeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase)); + } + + private static JsonObject ToJson(Student student) => new() + { + ["record_id"] = ProfileRecordIdentity.EnsureRecordId(student), + ["id"] = student.Id, + ["name"] = student.Name, + ["group"] = student.Group, + ["gender"] = student.Gender, + ["tags"] = student.Tags, + ["exists"] = student.Exists + }; + + private static async Task ReadBodyAsync(HttpListenerRequest request, CancellationToken cancellationToken) + { + var body = await JsonNode.ParseAsync(request.InputStream, cancellationToken: cancellationToken).ConfigureAwait(false) as JsonObject; + return body ?? throw new ArgumentException("Request body must be a JSON object."); + } + + private static string StringArgument(JsonObject arguments, string name) => arguments[name]?.GetValue()?.Trim() ?? string.Empty; + private static Guid? ParseGuid(string? value) => Guid.TryParse(value, out var result) ? result : null; + + private static IReadOnlyList StringArray(JsonObject arguments, string name) + => arguments[name] is JsonArray array + ? array.Select(item => item?.GetValue()?.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Cast().ToArray() + : []; + + private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode value, CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(value.ToJsonString(JsonOptions)); + response.ContentType = "application/json"; + response.ContentEncoding = Encoding.UTF8; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + private static Task WriteErrorAsync(HttpListenerResponse response, HttpStatusCode status, string message) + { + response.StatusCode = (int)status; + return WriteJsonAsync(response, new JsonObject { ["error"] = message }, CancellationToken.None); + } +} diff --git a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs new file mode 100644 index 00000000..070d1626 --- /dev/null +++ b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SecRandom.Services.SecAgent; + +/// +/// Quietly asks a running local SecAgent to install the SecRandom connector. +/// This is deliberately best-effort: SecRandom remains fully usable without SecAgent. +/// +public sealed class SecAgentPluginBootstrapHostedService( + IHttpClientFactory httpClientFactory, + ILogger logger) : BackgroundService +{ + private static readonly Uri BaseUri = new("http://127.0.0.1:42189/"); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Give the desktop host time to finish its own startup, and never hold up the UI. + try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EnsurePluginAsync(stoppingToken).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (HttpRequestException) + { + // SecAgent is optional and may simply not be installed/running. + } + catch (Exception ex) + { + logger.LogDebug(ex, "SecAgent connector bootstrap was skipped."); + return; + } + + try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + } + + private async Task EnsurePluginAsync(CancellationToken cancellationToken) + { + using var client = httpClientFactory.CreateClient(); + client.BaseAddress = BaseUri; + client.Timeout = TimeSpan.FromSeconds(2); + + using var health = await client.GetAsync("health", cancellationToken).ConfigureAwait(false); + if (!health.IsSuccessStatusCode) return; + + var installed = await client.GetFromJsonAsync("plugins", JsonOptions, cancellationToken).ConfigureAwait(false); + if (installed?.Plugins?.Any(plugin => string.Equals(plugin.Id, "secrandom", StringComparison.OrdinalIgnoreCase)) == true) + return; + + using var response = await client.PostAsJsonAsync("plugins/install", new { pluginId = "secrandom" }, JsonOptions, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + logger.LogInformation("Requested local SecAgent to install the SecRandom connector plugin."); + else + logger.LogDebug("Local SecAgent declined SecRandom connector installation with HTTP {StatusCode}.", response.StatusCode); + } + + private sealed class PluginListResponse + { + public List? Plugins { get; init; } + } + + private sealed class PluginInfo + { + public string? Id { get; init; } + } +} diff --git a/SecRandom/Views/FirstRunOobeWindow.axaml b/SecRandom/Views/FirstRunOobeWindow.axaml index 9930cec1..c6062bfa 100644 --- a/SecRandom/Views/FirstRunOobeWindow.axaml +++ b/SecRandom/Views/FirstRunOobeWindow.axaml @@ -37,7 +37,10 @@ - + + - + @@ -223,8 +225,7 @@ - + @@ -394,4 +395,4 @@ - \ No newline at end of file + diff --git a/SecRandom/Views/FirstRunOobeWindow.axaml.cs b/SecRandom/Views/FirstRunOobeWindow.axaml.cs index 3bcd4043..608b1299 100644 --- a/SecRandom/Views/FirstRunOobeWindow.axaml.cs +++ b/SecRandom/Views/FirstRunOobeWindow.axaml.cs @@ -81,11 +81,6 @@ private async void Next_OnClick(object? sender, RoutedEventArgs e) Close(); } - private void Appearance_OnChanged(object? sender, SelectionChangedEventArgs e) - { - ViewModel.RefreshAppearance(); - } - private void Language_OnChanged(object? sender, SelectionChangedEventArgs e) { if (!_isLanguageSelectionReady) From 1f8b5700359180526be7ab259f93d62eaf777aca Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 11:18:09 +0800 Subject: [PATCH 2/7] feat: automatically reset completed student draw rounds --- SecRandom.Core/Services/Draw/DrawEngine.cs | 27 ++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/SecRandom.Core/Services/Draw/DrawEngine.cs b/SecRandom.Core/Services/Draw/DrawEngine.cs index 92253797..b4ac5792 100644 --- a/SecRandom.Core/Services/Draw/DrawEngine.cs +++ b/SecRandom.Core/Services/Draw/DrawEngine.cs @@ -46,6 +46,24 @@ public DrawResult DrawStudent( Func filter, DrawSettingsType drawSettingsType, string courseName = "") + { + var result = DrawStudentCore(count, filter, drawSettingsType, courseName); + if (result.Status != DrawStatus.RepeatLimitExhausted + || !ShouldAutoResetStudentRound(count, filter, drawSettingsType)) + return result; + + var listName = StudentList.Name; + _profileService.ClearCurrentStudentHistory(); + IAppHost.TryGetService()?.ClearStudentList(listName); + _logger.LogInformation("学生抽取已完成一轮,已自动清空名单 {StudentListName} 的历史和临时记录并开始新一轮。", listName); + return DrawStudentCore(count, filter, drawSettingsType, courseName); + } + + private DrawResult DrawStudentCore( + int count, + Func filter, + DrawSettingsType drawSettingsType, + string courseName) { var hasBaseCandidates = false; var repeatThreshold = GetStudentRepeatThreshold(drawSettingsType); @@ -101,6 +119,15 @@ bool Filter1(Student student) } } + private bool ShouldAutoResetStudentRound(int count, Func filter, DrawSettingsType drawSettingsType) + { + if (count <= 0 || GetStudentRepeatThreshold(drawSettingsType) <= 0) + return false; + + var baseCandidateCount = StudentList.Students.Count(student => student.IsCandidate && filter(student)); + return baseCandidateCount >= count; + } + public DrawResult DrawStudent(int count, IReadOnlyCollection candidates, string courseName = "") { var candidateSet = candidates as HashSet ?? candidates.ToHashSet(); From 3dd27ad46bcf6e35f6d7b8b606fe3ff9ab585c1e Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 12:24:11 +0800 Subject: [PATCH 3/7] feat: auto-update SecRandom connector plugin --- .../SecAgentPluginBootstrapHostedService.cs | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs index 070d1626..daa45dcc 100644 --- a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs +++ b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs @@ -19,6 +19,8 @@ public sealed class SecAgentPluginBootstrapHostedService( IHttpClientFactory httpClientFactory, ILogger logger) : BackgroundService { + private const string ConnectorPluginId = "secrandom"; + private const string ConnectorPluginVersion = "0.1.1"; private static readonly Uri BaseUri = new("http://127.0.0.1:42189/"); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -64,12 +66,17 @@ private async Task EnsurePluginAsync(CancellationToken cancellationToken) if (!health.IsSuccessStatusCode) return; var installed = await client.GetFromJsonAsync("plugins", JsonOptions, cancellationToken).ConfigureAwait(false); - if (installed?.Plugins?.Any(plugin => string.Equals(plugin.Id, "secrandom", StringComparison.OrdinalIgnoreCase)) == true) + var current = installed?.Plugins?.FirstOrDefault(plugin => + string.Equals(plugin.Id, ConnectorPluginId, StringComparison.OrdinalIgnoreCase)); + if (current is not null && !IsOlderVersion(current.Version, ConnectorPluginVersion)) return; - using var response = await client.PostAsJsonAsync("plugins/install", new { pluginId = "secrandom" }, JsonOptions, cancellationToken).ConfigureAwait(false); + var request = current is null + ? new { pluginId = ConnectorPluginId, version = (string?)null } + : new { pluginId = ConnectorPluginId, version = (string?)ConnectorPluginVersion }; + using var response = await client.PostAsJsonAsync("plugins/install", request, JsonOptions, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) - logger.LogInformation("Requested local SecAgent to install the SecRandom connector plugin."); + logger.LogInformation("Requested local SecAgent to install/update the SecRandom connector plugin to {Version}.", ConnectorPluginVersion); else logger.LogDebug("Local SecAgent declined SecRandom connector installation with HTTP {StatusCode}.", response.StatusCode); } @@ -82,5 +89,14 @@ private sealed class PluginListResponse private sealed class PluginInfo { public string? Id { get; init; } + public string? Version { get; init; } + } + + private static bool IsOlderVersion(string? current, string desired) + { + if (Version.TryParse(current, out var currentVersion) && Version.TryParse(desired, out var desiredVersion)) + return currentVersion < desiredVersion; + + return !string.Equals(current, desired, StringComparison.OrdinalIgnoreCase); } } From 1ca31f1fd117093408645b00335ac5a7f785d55a Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 13:56:49 +0800 Subject: [PATCH 4/7] chore: remove plugin regression from merge --- SecRandom.Core/Plugins/PluginDescriptor.cs | 16 - SecRandom.Core/Plugins/PluginDrawContracts.cs | 25 - SecRandom.Core/Plugins/PluginManifest.cs | 16 - .../Plugins/PluginPageRegistration.cs | 33 -- .../Plugins/PluginRuntimeContracts.cs | 38 -- SecRandom.Core/Plugins/PluginStatus.cs | 11 - SecRandom/Services/Plugins/IPluginManager.cs | 47 -- .../Plugins/LoadedPluginRegistration.cs | 11 - .../Services/Plugins/PluginBuildContext.cs | 10 - .../Plugins/PluginCatalogHostedService.cs | 29 -- .../Services/Plugins/PluginCatalogService.cs | 452 ----------------- .../Services/Plugins/PluginDrawInvoker.cs | 124 ----- .../Services/Plugins/PluginHostedService.cs | 39 -- .../Services/Plugins/PluginManagerService.cs | 466 ------------------ .../Services/Plugins/PluginRuntimeContext.cs | 18 - .../Services/Plugins/PluginSelectionState.cs | 6 - .../Plugins/PluginServiceCollection.cs | 29 -- .../Services/Plugins/PluginStateStore.cs | 84 ---- .../SecAgent/SecAgentHttpHostedService.cs | 294 ----------- .../SecAgentPluginBootstrapHostedService.cs | 102 ---- 20 files changed, 1850 deletions(-) delete mode 100644 SecRandom.Core/Plugins/PluginDescriptor.cs delete mode 100644 SecRandom.Core/Plugins/PluginDrawContracts.cs delete mode 100644 SecRandom.Core/Plugins/PluginManifest.cs delete mode 100644 SecRandom.Core/Plugins/PluginPageRegistration.cs delete mode 100644 SecRandom.Core/Plugins/PluginRuntimeContracts.cs delete mode 100644 SecRandom.Core/Plugins/PluginStatus.cs delete mode 100644 SecRandom/Services/Plugins/IPluginManager.cs delete mode 100644 SecRandom/Services/Plugins/LoadedPluginRegistration.cs delete mode 100644 SecRandom/Services/Plugins/PluginBuildContext.cs delete mode 100644 SecRandom/Services/Plugins/PluginCatalogHostedService.cs delete mode 100644 SecRandom/Services/Plugins/PluginCatalogService.cs delete mode 100644 SecRandom/Services/Plugins/PluginDrawInvoker.cs delete mode 100644 SecRandom/Services/Plugins/PluginHostedService.cs delete mode 100644 SecRandom/Services/Plugins/PluginManagerService.cs delete mode 100644 SecRandom/Services/Plugins/PluginRuntimeContext.cs delete mode 100644 SecRandom/Services/Plugins/PluginSelectionState.cs delete mode 100644 SecRandom/Services/Plugins/PluginServiceCollection.cs delete mode 100644 SecRandom/Services/Plugins/PluginStateStore.cs delete mode 100644 SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs delete mode 100644 SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs diff --git a/SecRandom.Core/Plugins/PluginDescriptor.cs b/SecRandom.Core/Plugins/PluginDescriptor.cs deleted file mode 100644 index a5e3088e..00000000 --- a/SecRandom.Core/Plugins/PluginDescriptor.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace SecRandom.Core.Plugins; - -public sealed class PluginDescriptor -{ - public required PluginManifest Manifest { get; init; } - public required string DirectoryPath { get; init; } - public PluginStatus Status { get; init; } = PluginStatus.Discovered; - public bool IsEnabled { get; init; } - public bool RequiresRestart { get; init; } - public string? ErrorMessage { get; init; } - - public string Id => Manifest.Id; - public string Name => string.IsNullOrWhiteSpace(Manifest.Name) ? Manifest.Id : Manifest.Name; - public string Version => Manifest.Version; - public string Author => Manifest.Author; -} diff --git a/SecRandom.Core/Plugins/PluginDrawContracts.cs b/SecRandom.Core/Plugins/PluginDrawContracts.cs deleted file mode 100644 index a12bdd7f..00000000 --- a/SecRandom.Core/Plugins/PluginDrawContracts.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace SecRandom.Core.Plugins; - -public sealed class PluginStudentDrawRequest -{ - public int Count { get; init; } = 1; - public IReadOnlyList IncludeTags { get; init; } = []; - public IReadOnlyList ExcludeTags { get; init; } = []; -} - -public sealed class PluginPrizeDrawRequest -{ - public int Count { get; init; } = 1; -} - -public sealed class PluginDrawResult -{ - public required string Status { get; init; } - public int ResultCount { get; init; } -} - -public interface IPluginDrawInvoker -{ - Task DrawStudentsAsync(PluginStudentDrawRequest request); - Task DrawPrizesAsync(PluginPrizeDrawRequest request); -} diff --git a/SecRandom.Core/Plugins/PluginManifest.cs b/SecRandom.Core/Plugins/PluginManifest.cs deleted file mode 100644 index a61b21ba..00000000 --- a/SecRandom.Core/Plugins/PluginManifest.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Text.Json.Serialization; - -namespace SecRandom.Core.Plugins; - -public sealed class PluginManifest -{ - [JsonPropertyName("id")] public string Id { get; init; } = string.Empty; - [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; - [JsonPropertyName("version")] public string Version { get; init; } = string.Empty; - [JsonPropertyName("author")] public string Author { get; init; } = string.Empty; - [JsonPropertyName("description")] public string Description { get; init; } = string.Empty; - [JsonPropertyName("apiVersion")] public string ApiVersion { get; init; } = string.Empty; - [JsonPropertyName("minimumHostVersion")] public string MinimumHostVersion { get; init; } = string.Empty; - [JsonPropertyName("entryAssembly")] public string EntryAssembly { get; init; } = string.Empty; - [JsonPropertyName("entryType")] public string EntryType { get; init; } = string.Empty; -} diff --git a/SecRandom.Core/Plugins/PluginPageRegistration.cs b/SecRandom.Core/Plugins/PluginPageRegistration.cs deleted file mode 100644 index 2813defc..00000000 --- a/SecRandom.Core/Plugins/PluginPageRegistration.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Avalonia.Controls; -using SecRandom.Core.Enums; - -namespace SecRandom.Core.Plugins; - -public sealed class PluginPageRegistration -{ - public required string PluginId { get; init; } - public required string PageId { get; init; } - public required string Name { get; init; } - public required string IconGlyph { get; init; } - public required Type PageType { get; init; } - public string? GroupId { get; init; } - public PageLocation Location { get; init; } = PageLocation.Top; - public bool IsHide { get; init; } - public bool UseFullWidth { get; init; } - public bool HidePageTitle { get; init; } - - public void Validate() - { - if (string.IsNullOrWhiteSpace(PluginId)) - throw new ArgumentException("Plugin id is required.", nameof(PluginId)); - - if (!PageId.StartsWith($"plugin.{PluginId}.", StringComparison.Ordinal)) - throw new ArgumentException($"Plugin page id must start with plugin.{PluginId}.", nameof(PageId)); - - if (!typeof(UserControl).IsAssignableFrom(PageType)) - throw new ArgumentException("Plugin page type must inherit Avalonia.Controls.UserControl.", nameof(PageType)); - - if (string.IsNullOrWhiteSpace(Name)) - throw new ArgumentException("Plugin page name is required.", nameof(Name)); - } -} diff --git a/SecRandom.Core/Plugins/PluginRuntimeContracts.cs b/SecRandom.Core/Plugins/PluginRuntimeContracts.cs deleted file mode 100644 index 61567f70..00000000 --- a/SecRandom.Core/Plugins/PluginRuntimeContracts.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Microsoft.Extensions.Logging; - -namespace SecRandom.Core.Plugins; - -public interface ISecRandomPlugin -{ - string Id { get; } - void ConfigureServices(IPluginServiceCollection services, IPluginBuildContext context); - Task OnLoadedAsync(IPluginRuntimeContext context); -} - -public interface IPluginServiceCollection -{ - void AddMainPage(PluginPageRegistration registration); - void AddSettingsPage(PluginPageRegistration registration); -} - -public interface IPluginBuildContext -{ - PluginManifest Manifest { get; } - PluginInfo PluginInfo { get; } -} - -public interface IPluginRuntimeContext -{ - PluginManifest Manifest { get; } - PluginInfo PluginInfo { get; } - ILogger Logger { get; } - IPluginDrawInvoker? DrawInvoker { get; } - string DataDirectory { get; } -} - -public sealed class PluginInfo -{ - public required PluginManifest Manifest { get; init; } - public required string PluginDirectory { get; init; } - public required string ConfigDirectory { get; init; } -} diff --git a/SecRandom.Core/Plugins/PluginStatus.cs b/SecRandom.Core/Plugins/PluginStatus.cs deleted file mode 100644 index 1af0ee3f..00000000 --- a/SecRandom.Core/Plugins/PluginStatus.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace SecRandom.Core.Plugins; - -public enum PluginStatus -{ - Discovered, - Disabled, - Loaded, - LoadFailed, - Incompatible, - PendingRestart -} diff --git a/SecRandom/Services/Plugins/IPluginManager.cs b/SecRandom/Services/Plugins/IPluginManager.cs deleted file mode 100644 index 68c8eff6..00000000 --- a/SecRandom/Services/Plugins/IPluginManager.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using SecRandom.Core.Plugins; - -namespace SecRandom.Services.Plugins; - -public interface IPluginManager -{ - IReadOnlyList Plugins { get; } - void Refresh(); - void SetEnabled(string pluginId, bool isEnabled); - string ImportPluginDirectory(string sourceDirectory); - string ImportPluginPackage(string packagePath); - IEnumerable GetPluginLogs(string pluginId, int maxEntries = 500); -} - -public enum PluginImportFailureReason -{ - InvalidFolder, - InvalidManifest, - InvalidPackage, - AlreadyExists, - CopyFailed -} - -public sealed class PluginImportException : Exception -{ - public PluginImportException(PluginImportFailureReason reason, string? message = null, Exception? innerException = null) - : base(message, innerException) - { - Reason = reason; - } - - public PluginImportFailureReason Reason { get; } -} - -public sealed record PluginLogEntry( - DateTime Time, - string Level, - string Category, - string Message) -{ - public string TimeText => Time.ToString("yyyy-MM-dd HH:mm:ss"); - public string ShortCategory => Category.Split('.').LastOrDefault() ?? Category; - public string Preview => Message.Replace(Environment.NewLine, " "); -} diff --git a/SecRandom/Services/Plugins/LoadedPluginRegistration.cs b/SecRandom/Services/Plugins/LoadedPluginRegistration.cs deleted file mode 100644 index 33c4c64f..00000000 --- a/SecRandom/Services/Plugins/LoadedPluginRegistration.cs +++ /dev/null @@ -1,11 +0,0 @@ -using SecRandom.Core.Plugins; - -namespace SecRandom.Services.Plugins; - -public sealed class LoadedPluginRegistration( - ISecRandomPlugin plugin, - IPluginRuntimeContext runtimeContext) -{ - public ISecRandomPlugin Plugin { get; } = plugin; - public IPluginRuntimeContext RuntimeContext { get; } = runtimeContext; -} diff --git a/SecRandom/Services/Plugins/PluginBuildContext.cs b/SecRandom/Services/Plugins/PluginBuildContext.cs deleted file mode 100644 index 590fa48d..00000000 --- a/SecRandom/Services/Plugins/PluginBuildContext.cs +++ /dev/null @@ -1,10 +0,0 @@ -using SecRandom.Core.Plugins; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginBuildContext(PluginManifest manifest, PluginInfo pluginInfo) - : IPluginBuildContext -{ - public PluginManifest Manifest { get; } = manifest; - public PluginInfo PluginInfo { get; } = pluginInfo; -} diff --git a/SecRandom/Services/Plugins/PluginCatalogHostedService.cs b/SecRandom/Services/Plugins/PluginCatalogHostedService.cs deleted file mode 100644 index 918f339e..00000000 --- a/SecRandom/Services/Plugins/PluginCatalogHostedService.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginCatalogHostedService( - IPluginCatalogService pluginCatalog, - ILogger logger) : IHostedService -{ - public async Task StartAsync(CancellationToken cancellationToken) - { - try - { - await pluginCatalog.RefreshAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogWarning(ex, "启动时刷新插件源失败。"); - } - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/SecRandom/Services/Plugins/PluginCatalogService.cs b/SecRandom/Services/Plugins/PluginCatalogService.cs deleted file mode 100644 index 7b9cc9fa..00000000 --- a/SecRandom/Services/Plugins/PluginCatalogService.cs +++ /dev/null @@ -1,452 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Text.Json; -using System.Text.Json.Serialization; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; -using SecRandom.Shared; - -namespace SecRandom.Services.Plugins; - -public interface IPluginCatalogService -{ - IReadOnlyList Entries { get; } - IReadOnlyList Mirrors { get; } - IReadOnlyList Sources { get; } - PluginCatalogMirror SelectedMirror { get; } - bool IgnoreTlsCertificateErrors { get; } - void SelectMirror(string mirrorId); - PluginCatalogSource AddSource(string url, string mirrorUrl); - PluginCatalogSource UpdateSource(string sourceId, string url, string mirrorUrl); - void RemoveSource(string sourceId); - void SetIgnoreTlsCertificateErrors(bool value); - Task> RefreshAsync(CancellationToken cancellationToken = default); - Task InstallAsync(PluginCatalogEntry entry, IPluginManager pluginManager, CancellationToken cancellationToken = default); -} - -public sealed class PluginCatalogService : IPluginCatalogService, IDisposable -{ - private const string BuiltInSecTlMirrorId = "sectl"; - private const string BuiltInGitHubMirrorId = "github"; - private const string OfficialIndexUrl = "https://raw.githubusercontent.com/SECTL/SecRandom-PluginIndex/main/index/index.json"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true, - WriteIndented = true - }; - - private readonly ILogger _logger; - private readonly List _entries = []; - private readonly List _mirrors = - [ - new() { Id = BuiltInSecTlMirrorId, Name = "SECTL", Url = "https://ghproxy.sectl.cn/" }, - new() { Id = BuiltInGitHubMirrorId, Name = "GitHub", Url = string.Empty } - ]; - private readonly List _sources = []; - private HttpClient _httpClient; - private string _selectedMirrorId = BuiltInSecTlMirrorId; - private bool _ignoreTlsCertificateErrors; - private bool _stateLoaded; - - public PluginCatalogService(ILogger logger) - { - _logger = logger; - _httpClient = CreateHttpClient(false); - } - - public IReadOnlyList Entries => new ReadOnlyCollection(_entries); - - public IReadOnlyList Mirrors - { - get - { - EnsureStateLoaded(); - return new ReadOnlyCollection(_mirrors); - } - } - - public IReadOnlyList Sources - { - get - { - EnsureStateLoaded(); - return new ReadOnlyCollection(_sources); - } - } - - public PluginCatalogMirror SelectedMirror - { - get - { - EnsureStateLoaded(); - return _mirrors.FirstOrDefault(x => x.Id == _selectedMirrorId) ?? _mirrors[0]; - } - } - - public bool IgnoreTlsCertificateErrors - { - get - { - EnsureStateLoaded(); - return _ignoreTlsCertificateErrors; - } - } - - private static string StateFilePath => Utils.GetFilePath("plugins", "plugin-sources.json"); - - public void SelectMirror(string mirrorId) - { - EnsureStateLoaded(); - if (_mirrors.All(x => x.Id != mirrorId)) - return; - - _selectedMirrorId = mirrorId; - SaveState(); - } - - public PluginCatalogSource AddSource(string url, string mirrorUrl) - { - EnsureStateLoaded(); - var normalizedUrl = NormalizeSourceUrl(url); - var normalizedMirrorUrl = NormalizeOptionalMirrorUrl(mirrorUrl); - var existing = _sources.FirstOrDefault(x => string.Equals(x.Url, normalizedUrl, StringComparison.OrdinalIgnoreCase)); - if (existing != null) - return existing; - - var source = new PluginCatalogSource - { - Id = "custom-" + Guid.NewGuid().ToString("N"), - Name = new Uri(normalizedUrl).Host, - Url = normalizedUrl, - MirrorUrl = normalizedMirrorUrl - }; - - _sources.Add(source); - SaveState(); - return source; - } - - public PluginCatalogSource UpdateSource(string sourceId, string url, string mirrorUrl) - { - EnsureStateLoaded(); - var index = _sources.FindIndex(x => x.Id == sourceId); - if (index < 0) - throw new ArgumentException("Plugin source was not found.", nameof(sourceId)); - - var normalizedUrl = NormalizeSourceUrl(url); - var normalizedMirrorUrl = NormalizeOptionalMirrorUrl(mirrorUrl); - var source = _sources[index] with - { - Name = new Uri(normalizedUrl).Host, - Url = normalizedUrl, - MirrorUrl = normalizedMirrorUrl, - IsBuiltIn = false - }; - _sources[index] = source; - SaveState(); - return source; - } - - public void RemoveSource(string sourceId) - { - EnsureStateLoaded(); - var source = _sources.FirstOrDefault(x => x.Id == sourceId); - if (source == null) - return; - - _sources.Remove(source); - SaveState(); - } - - public void SetIgnoreTlsCertificateErrors(bool value) - { - EnsureStateLoaded(); - if (_ignoreTlsCertificateErrors == value) - return; - - _ignoreTlsCertificateErrors = value; - var oldClient = _httpClient; - _httpClient = CreateHttpClient(value); - oldClient.Dispose(); - SaveState(); - } - - public async Task> RefreshAsync(CancellationToken cancellationToken = default) - { - EnsureStateLoaded(); - var nextEntries = new List(); - var errors = new List(); - - try - { - nextEntries.AddRange(await LoadEntriesAsync(OfficialIndexUrl, SelectedMirror.Url, cancellationToken).ConfigureAwait(false)); - } - catch (Exception ex) - { - errors.Add(ex); - _logger.LogWarning(ex, "Failed to refresh the built-in plugin source."); - } - - foreach (var source in _sources) - { - try - { - nextEntries.AddRange(await LoadEntriesAsync(source.Url, source.MirrorUrl, cancellationToken).ConfigureAwait(false)); - } - catch (Exception ex) - { - errors.Add(ex); - _logger.LogWarning(ex, "Failed to refresh custom plugin source: {SourceUrl}", source.Url); - } - } - - if (nextEntries.Count == 0 && errors.Count > 0) - throw errors[0]; - - _entries.Clear(); - foreach (var entry in nextEntries - .Where(x => !string.IsNullOrWhiteSpace(x.Id)) - .GroupBy(x => x.Id, StringComparer.Ordinal) - .Select(x => x.First())) - _entries.Add(entry); - - _logger.LogInformation("Plugin catalog refreshed: {Count}", _entries.Count); - return Entries; - } - - public async Task InstallAsync(PluginCatalogEntry entry, IPluginManager pluginManager, CancellationToken cancellationToken = default) - { - EnsureStateLoaded(); - if (string.IsNullOrWhiteSpace(entry.PackageUrl)) - throw new PluginImportException(PluginImportFailureReason.InvalidPackage, "Plugin catalog entry does not provide a package URL."); - - var tempPackagePath = Path.Combine(Path.GetTempPath(), "SecRandomPluginCatalog", $"{entry.Id}-{Guid.NewGuid():N}.srpx"); - Directory.CreateDirectory(Path.GetDirectoryName(tempPackagePath)!); - - try - { - await using (var source = await OpenAsync(entry.PackageUrl, entry.SourceMirrorUrl, cancellationToken).ConfigureAwait(false)) - await using (var destination = File.Create(tempPackagePath)) - { - await source.CopyToAsync(destination, cancellationToken).ConfigureAwait(false); - } - - return pluginManager.ImportPluginPackage(tempPackagePath); - } - finally - { - try - { - if (File.Exists(tempPackagePath)) - File.Delete(tempPackagePath); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to clean temporary plugin package: {PackagePath}", tempPackagePath); - } - } - } - - public void Dispose() - { - _httpClient.Dispose(); - } - - private static HttpClient CreateHttpClient(bool ignoreTlsCertificateErrors) - { - var handler = new HttpClientHandler(); - if (ignoreTlsCertificateErrors) - handler.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator; - - return new HttpClient(handler) - { - Timeout = TimeSpan.FromSeconds(30) - }; - } - - private async Task> LoadEntriesAsync(string indexUrl, string mirrorUrl, CancellationToken cancellationToken) - { - var stream = await OpenAsync(indexUrl, mirrorUrl, cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) - { - var document = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false) - ?? new PluginCatalogDocument(); - - foreach (var entry in document.Plugins) - entry.SourceMirrorUrl = mirrorUrl; - - return document.Plugins; - } - } - - private async Task OpenAsync(string url, string mirrorUrl, CancellationToken cancellationToken) - { - var candidate = BuildSourceUrl(url, mirrorUrl); - _logger.LogDebug("Reading plugin catalog resource: {Url}", candidate); - return await _httpClient.GetStreamAsync(candidate, cancellationToken).ConfigureAwait(false); - } - - private static string BuildSourceUrl(string url, string mirrorUrl) - { - if (string.IsNullOrWhiteSpace(mirrorUrl)) - return url; - - if (url.StartsWith(mirrorUrl, StringComparison.OrdinalIgnoreCase)) - return url; - - if (!url.StartsWith("https://github.com/", StringComparison.OrdinalIgnoreCase) && - !url.StartsWith("https://raw.githubusercontent.com/", StringComparison.OrdinalIgnoreCase)) - return url; - - return mirrorUrl + url; - } - - private static string NormalizeSourceUrl(string url) - { - var trimmed = url.Trim(); - if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) || - uri.Scheme is not ("http" or "https")) - throw new ArgumentException("Plugin source URL must be an absolute HTTP(S) URL.", nameof(url)); - - return trimmed; - } - - private static string NormalizeOptionalMirrorUrl(string url) - { - if (string.IsNullOrWhiteSpace(url)) - return string.Empty; - - return NormalizeMirrorUrl(url); - } - - private static string NormalizeMirrorUrl(string url) - { - var trimmed = url.Trim(); - if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri) || - uri.Scheme is not ("http" or "https")) - throw new ArgumentException("Plugin mirror URL must be an absolute HTTP(S) URL.", nameof(url)); - - return trimmed.TrimEnd('/') + "/"; - } - - private void LoadState() - { - if (!File.Exists(StateFilePath)) - return; - - try - { - var state = JsonSerializer.Deserialize(File.ReadAllText(StateFilePath), JsonOptions); - if (state == null) - return; - - foreach (var source in state.CustomSources) - { - if (!string.IsNullOrWhiteSpace(source.Id) && - !string.IsNullOrWhiteSpace(source.Url) && - _sources.All(x => x.Id != source.Id)) - _sources.Add(source with { IsBuiltIn = false }); - } - - var selectedMirrorId = string.IsNullOrWhiteSpace(state.SelectedMirrorId) - ? state.SelectedSourceId - : state.SelectedMirrorId; - if (!string.IsNullOrWhiteSpace(selectedMirrorId) && - _mirrors.Any(x => x.Id == selectedMirrorId)) - _selectedMirrorId = selectedMirrorId; - - _ignoreTlsCertificateErrors = state.IgnoreTlsCertificateErrors; - _httpClient.Dispose(); - _httpClient = CreateHttpClient(_ignoreTlsCertificateErrors); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load plugin source settings."); - } - } - - private void EnsureStateLoaded() - { - if (_stateLoaded) - return; - - LoadState(); - _stateLoaded = true; - } - - private void SaveState() - { - Directory.CreateDirectory(Path.GetDirectoryName(StateFilePath)!); - var state = new PluginCatalogSourceState - { - SelectedMirrorId = _selectedMirrorId, - IgnoreTlsCertificateErrors = _ignoreTlsCertificateErrors, - CustomSources = _sources.Select(x => x with { IsBuiltIn = false }).ToList() - }; - - File.WriteAllText(StateFilePath, JsonSerializer.Serialize(state, JsonOptions)); - } -} - -public sealed class PluginCatalogDocument -{ - [JsonPropertyName("plugins")] public IReadOnlyList Plugins { get; init; } = []; -} - -public sealed class PluginCatalogEntry -{ - [JsonPropertyName("id")] public string Id { get; init; } = string.Empty; - [JsonPropertyName("name")] public string Name { get; init; } = string.Empty; - [JsonPropertyName("description")] public string Description { get; init; } = string.Empty; - [JsonPropertyName("author")] public string Author { get; init; } = string.Empty; - [JsonPropertyName("version")] public string Version { get; init; } = string.Empty; - [JsonPropertyName("apiVersion")] public string ApiVersion { get; init; } = string.Empty; - [JsonPropertyName("minimumHostVersion")] public string MinimumHostVersion { get; init; } = string.Empty; - [JsonPropertyName("packageUrl")] public string PackageUrl { get; init; } = string.Empty; - [JsonPropertyName("projectUrl")] public string ProjectUrl { get; init; } = string.Empty; - [JsonPropertyName("readme")] public string Readme { get; init; } = string.Empty; - [JsonPropertyName("stars")] public int Stars { get; init; } - [JsonPropertyName("downloads")] public int Downloads { get; init; } - [JsonIgnore] public string SourceMirrorUrl { get; internal set; } = string.Empty; - - public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Id : Name; -} - -public sealed record PluginCatalogMirror -{ - public string Id { get; init; } = string.Empty; - public string Name { get; init; } = string.Empty; - public string Url { get; init; } = string.Empty; - public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Url : Name; - public override string ToString() => DisplayName; -} - -public sealed record PluginCatalogSource -{ - public string Id { get; init; } = string.Empty; - public string Name { get; init; } = string.Empty; - public string Url { get; init; } = string.Empty; - public string MirrorUrl { get; init; } = string.Empty; - public bool IsBuiltIn { get; init; } - public string DisplayName => string.IsNullOrWhiteSpace(Name) ? Url : Name; - public override string ToString() => DisplayName; -} - -public sealed class PluginCatalogSourceState -{ - public string SelectedMirrorId { get; init; } = BuiltInSourceDefaults.SelectedMirrorId; - public string SelectedSourceId { get; init; } = string.Empty; - public bool IgnoreTlsCertificateErrors { get; init; } - public IReadOnlyList CustomSources { get; init; } = []; -} - -internal static class BuiltInSourceDefaults -{ - public const string SelectedMirrorId = "sectl"; -} diff --git a/SecRandom/Services/Plugins/PluginDrawInvoker.cs b/SecRandom/Services/Plugins/PluginDrawInvoker.cs deleted file mode 100644 index 3fd9b453..00000000 --- a/SecRandom/Services/Plugins/PluginDrawInvoker.cs +++ /dev/null @@ -1,124 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using SecRandom.Core.Abstraction; -using SecRandom.Core.Abstraction.Services; -using Microsoft.Extensions.Logging; -using SecRandom.Core.Plugins; -using SecRandom.Core.Services.Draw; -using SecRandom.Core.Enums.Configs; -using SecRandom.Core.Services.Config; -using SecRandom.Services.Security; -using SecRandom.Services.Linkage; -using SecRandom.Services; -using SecRandom.Shared.Models.Profile; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginDrawInvoker( - string pluginId, - ILogger logger, - LinkageDrawCoordinator linkageDrawCoordinator, - IDrawTemporaryRecordService temporaryRecordService, - MainConfigHandler configHandler, - FeatureAvailabilityService featureAvailability) : IPluginDrawInvoker -{ - public async Task DrawStudentsAsync(PluginStudentDrawRequest request) - { - PluginDrawResult? response = null; - await linkageDrawCoordinator.AuthorizeAsync(SecurityOperation.RollCallStart, () => - { - var engine = new DrawEngine(); - var profileService = IAppHost.GetService(); - var studentListName = profileService.CurrentStudentList?.Name ?? string.Empty; - var temporaryCounts = temporaryRecordService.GetStudentCounts(studentListName, string.Empty, string.Empty); - var result = engine.DrawStudent(Math.Max(1, request.Count), student => - MatchesTags(student.Tags, request) && !HasReachedStudentRepeatLimit(student, temporaryCounts), - linkageDrawCoordinator.GetCourseName()); - if (result.IsSuccess && result.Result.Count > 0) - { - var courseName = linkageDrawCoordinator.GetCourseName(); - profileService.RecordStudentHistory( - result.Result, - DateTime.Now, - Math.Max(1, request.Count), - drawMethod: (int)configHandler.Data.RollCallSettings.DrawType, - courseName: courseName); - temporaryRecordService.RecordStudents(studentListName, string.Empty, string.Empty, result.Result); - } - logger.LogInformation( - "Plugin draw invoked: plugin={PluginId}, type=student, count={Count}, includeTags={IncludeTags}, excludeTags={ExcludeTags}, status={Status}, resultCount={ResultCount}.", - pluginId, request.Count, string.Join(",", request.IncludeTags), string.Join(",", request.ExcludeTags), result.Status, result.Result.Count); - response = new PluginDrawResult { Status = result.Status.ToString(), ResultCount = result.Result.Count }; - return Task.CompletedTask; - }); - - return response ?? new PluginDrawResult { Status = "AuthorizationRequired", ResultCount = 0 }; - } - - public async Task DrawPrizesAsync(PluginPrizeDrawRequest request) - { - if (!featureAvailability.IsLotteryEnabled) - { - logger.LogInformation("Plugin draw rejected because lottery is disabled: plugin={PluginId}.", pluginId); - return new PluginDrawResult { Status = "FeatureDisabled", ResultCount = 0 }; - } - - PluginDrawResult? response = null; - var disabledDuringAuthorization = false; - await linkageDrawCoordinator.AuthorizeAsync(SecurityOperation.LotteryStart, () => - { - if (!featureAvailability.IsLotteryEnabled) - { - disabledDuringAuthorization = true; - return Task.CompletedTask; - } - - var engine = new DrawEngine(); - var profileService = IAppHost.GetService(); - var prizeListName = profileService.CurrentPrizeList?.Name ?? string.Empty; - var requestedCount = Math.Max(1, request.Count); - var result = engine.DrawPrizeWithTemporaryCounts( - requestedCount, - _ => true, - temporaryRecordService.GetPrizeCounts(prizeListName)); - if (result.IsSuccess && result.Result.Count > 0) - { - profileService.RecordPrizeHistory(result.Result, DateTime.Now, requestedCount); - temporaryRecordService.RecordPrizes(prizeListName, result.Result); - } - logger.LogInformation( - "Plugin draw invoked: plugin={PluginId}, type=prize, count={Count}, status={Status}, resultCount={ResultCount}.", - pluginId, request.Count, result.Status, result.Result.Count); - response = new PluginDrawResult { Status = result.Status.ToString(), ResultCount = result.Result.Count }; - return Task.CompletedTask; - }); - - if (disabledDuringAuthorization) - return new PluginDrawResult { Status = "FeatureDisabled", ResultCount = 0 }; - return response ?? new PluginDrawResult { Status = "AuthorizationRequired", ResultCount = 0 }; - } - - private static bool MatchesTags(string tags, PluginStudentDrawRequest request) - { - var tagSet = tags.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - return request.IncludeTags.All(tagSet.Contains) && !request.ExcludeTags.Any(tagSet.Contains); - } - - private bool HasReachedStudentRepeatLimit(Student student, IReadOnlyDictionary temporaryCounts) - { - var settings = configHandler.Data.RollCallSettings; - var mode = settings.DrawMode; - var threshold = mode switch - { - DrawMode.Repeat => 0, - DrawMode.NoRepeat => 1, - DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat), - _ => 1 - }; - return threshold > 0 && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold; - } -} diff --git a/SecRandom/Services/Plugins/PluginHostedService.cs b/SecRandom/Services/Plugins/PluginHostedService.cs deleted file mode 100644 index 872c4883..00000000 --- a/SecRandom/Services/Plugins/PluginHostedService.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginHostedService( - IEnumerable plugins, - ILogger logger) : IHostedService -{ - public async Task StartAsync(CancellationToken cancellationToken) - { - foreach (var registration in plugins) - { - if (cancellationToken.IsCancellationRequested) - return; - - try - { - await registration.Plugin.OnLoadedAsync(registration.RuntimeContext).ConfigureAwait(false); - PluginManagerService.SetStartupResult(registration.Plugin.Id, Core.Plugins.PluginStatus.Loaded, null); - logger.LogInformation("插件运行时已启动:插件={PluginId}。", registration.Plugin.Id); - } - catch (Exception ex) - { - PluginManagerService.SetStartupResult(registration.Plugin.Id, Core.Plugins.PluginStatus.LoadFailed, ex.Message); - logger.LogError(ex, "插件运行时启动失败:插件={PluginId}。", registration.Plugin.Id); - } - } - } - - public Task StopAsync(CancellationToken cancellationToken) - { - return Task.CompletedTask; - } -} diff --git a/SecRandom/Services/Plugins/PluginManagerService.cs b/SecRandom/Services/Plugins/PluginManagerService.cs deleted file mode 100644 index ce31399e..00000000 --- a/SecRandom/Services/Plugins/PluginManagerService.cs +++ /dev/null @@ -1,466 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Collections.ObjectModel; -using System.IO; -using System.IO.Compression; -using System.Linq; -using System.Reflection; -using System.Text; -using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using SecRandom.Core; -using SecRandom.Core.Abstraction.Services; -using SecRandom.Core.Plugins; -using SecRandom.Core.Services.Config; -using SecRandom.Core.Services.Logging; -using SecRandom.Shared; -using SecRandom.Services.Security; -using SecRandom.Services.Linkage; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginManagerService : IPluginManager -{ - public const string SupportedApiVersion = "1"; - - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNameCaseInsensitive = true - }; - - private readonly ILogger _logger; - private readonly PluginStateStore _stateStore; - private readonly List _plugins = []; - private static readonly ConcurrentDictionary StartupResults = []; - - public PluginManagerService( - ILogger logger, - PluginStateStore stateStore) - { - _logger = logger; - _stateStore = stateStore; - Refresh(); - } - - public IReadOnlyList Plugins => new ReadOnlyCollection(_plugins); - - public static string PluginsDirectory => Utils.GetDirectoryPath("plugins"); - public static string PluginConfigsDirectory => Utils.GetDirectoryPath("configs", "plugins"); - - public static void ConfigureEnabledPlugins(IServiceCollection services, PluginStateStore stateStore) - { - Directory.CreateDirectory(PluginsDirectory); - var logger = NullLogger.Instance; - - foreach (var descriptor in DiscoverPlugins(stateStore, logger).Where(x => x.IsEnabled && x.Status == PluginStatus.Discovered)) - ConfigurePlugin(services, descriptor, stateStore, logger); - } - - public void Refresh() - { - Directory.CreateDirectory(PluginsDirectory); - _plugins.Clear(); - - foreach (var manifestPath in Directory.EnumerateFiles(PluginsDirectory, "plugin.json", SearchOption.AllDirectories)) - _plugins.Add(ReadDescriptor(manifestPath)); - - _logger.LogInformation("已扫描插件目录:插件数量={Count}", _plugins.Count); - } - - public void SetEnabled(string pluginId, bool isEnabled) - { - var state = _stateStore.GetOrCreate(pluginId); - state.IsEnabled = isEnabled; - state.RequiresRestart = true; - _stateStore.Save(); - Refresh(); - } - - public string ImportPluginDirectory(string sourceDirectory) - { - var resolvedSource = Path.GetFullPath(sourceDirectory); - if (!Directory.Exists(resolvedSource)) - throw new PluginImportException(PluginImportFailureReason.InvalidFolder, "Plugin directory does not exist."); - - var manifestPath = Path.Combine(resolvedSource, "plugin.json"); - if (!File.Exists(manifestPath)) - throw new PluginImportException(PluginImportFailureReason.InvalidManifest, "plugin.json was not found."); - - var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), JsonOptions) - ?? throw new PluginImportException(PluginImportFailureReason.InvalidManifest, "Plugin manifest is empty."); - - ValidateManifest(manifest, resolvedSource, out var error, allowMissingAssembly: true); - if (!string.IsNullOrWhiteSpace(error)) - throw new PluginImportException(PluginImportFailureReason.InvalidManifest, error); - - var targetDirectory = Path.Combine(PluginsDirectory, manifest.Id); - if (Directory.Exists(targetDirectory)) - throw new PluginImportException(PluginImportFailureReason.AlreadyExists, $"Plugin '{manifest.Id}' already exists."); - - try - { - DirectoryCopy(resolvedSource, targetDirectory); - } - catch (Exception ex) - { - throw new PluginImportException(PluginImportFailureReason.CopyFailed, "Failed to copy plugin files.", ex); - } - - Refresh(); - return manifest.Id; - } - - public string ImportPluginPackage(string packagePath) - { - var resolvedPackage = Path.GetFullPath(packagePath); - if (!File.Exists(resolvedPackage)) - throw new PluginImportException(PluginImportFailureReason.InvalidPackage, "Plugin package does not exist."); - - var tempDirectory = Path.Combine(Path.GetTempPath(), "SecRandomPluginImport", Guid.NewGuid().ToString("N")); - Directory.CreateDirectory(tempDirectory); - try - { - ZipFile.ExtractToDirectory(resolvedPackage, tempDirectory); - var sourceDirectory = ResolveExtractedPluginDirectory(tempDirectory); - return ImportPluginDirectory(sourceDirectory); - } - catch (InvalidDataException ex) - { - throw new PluginImportException(PluginImportFailureReason.InvalidPackage, "Plugin package is not a valid archive.", ex); - } - finally - { - try - { - if (Directory.Exists(tempDirectory)) - Directory.Delete(tempDirectory, true); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "清理插件导入临时目录失败:目录={TempDirectory}", tempDirectory); - } - } - } - - public IEnumerable GetPluginLogs(string pluginId, int maxEntries = 500) - { - var prefix = GetPluginLogCategoryPrefix(pluginId); - var current = Directory.EnumerateFiles(FileLoggerProvider.LogDirectory) - .Where(path => path.EndsWith(".log", StringComparison.OrdinalIgnoreCase)) - .OrderByDescending(File.GetLastWriteTime) - .FirstOrDefault(); - - if (current == null) - return []; - - return ParseLogEntries(ReadTailLines(current, Math.Max(maxEntries * 5, 500))) - .Where(entry => entry.Category.StartsWith(prefix, StringComparison.Ordinal)) - .TakeLast(maxEntries) - .ToList(); - } - - public static string GetPluginLogCategoryPrefix(string pluginId) - { - return $"SecRandom.Plugin[{pluginId}]."; - } - - internal static void SetStartupResult(string pluginId, PluginStatus status, string? error) - { - StartupResults[pluginId] = (status, error); - } - - private PluginDescriptor ReadDescriptor(string manifestPath) - { - try - { - var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), JsonOptions) - ?? throw new InvalidOperationException("Plugin manifest is empty."); - - var state = _stateStore.GetOrCreate(manifest.Id); - var status = ValidateManifest(manifest, Path.GetDirectoryName(manifestPath)!, out var error); - if (!state.IsEnabled && status == PluginStatus.Discovered) - status = PluginStatus.Disabled; - - if (state.RequiresRestart) - status = PluginStatus.PendingRestart; - - var startupResult = StartupResults.GetValueOrDefault(manifest.Id); - if (state.IsEnabled && startupResult.Status != default) - { - status = startupResult.Status; - error = startupResult.Error; - } - - return new PluginDescriptor - { - Manifest = manifest, - DirectoryPath = Path.GetDirectoryName(manifestPath)!, - IsEnabled = state.IsEnabled, - RequiresRestart = state.RequiresRestart, - Status = status, - ErrorMessage = error - }; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "读取插件清单失败:文件={ManifestPath}", manifestPath); - return new PluginDescriptor - { - Manifest = new PluginManifest - { - Id = Path.GetFileName(Path.GetDirectoryName(manifestPath)) ?? "unknown", - Name = Path.GetFileName(Path.GetDirectoryName(manifestPath)) ?? "unknown" - }, - DirectoryPath = Path.GetDirectoryName(manifestPath)!, - Status = PluginStatus.LoadFailed, - ErrorMessage = ex.Message - }; - } - } - - private static IReadOnlyList DiscoverPlugins(PluginStateStore stateStore, ILogger logger) - { - List plugins = []; - foreach (var manifestPath in Directory.EnumerateFiles(PluginsDirectory, "plugin.json", SearchOption.AllDirectories)) - { - try - { - var manifest = JsonSerializer.Deserialize(File.ReadAllText(manifestPath), JsonOptions) - ?? throw new InvalidOperationException("Plugin manifest is empty."); - var state = stateStore.GetOrCreate(manifest.Id); - var status = ValidateManifest(manifest, Path.GetDirectoryName(manifestPath)!, out var error); - if (!state.IsEnabled && status == PluginStatus.Discovered) - status = PluginStatus.Disabled; - - if (state.RequiresRestart) - status = PluginStatus.PendingRestart; - - plugins.Add(new PluginDescriptor - { - Manifest = manifest, - DirectoryPath = Path.GetDirectoryName(manifestPath)!, - IsEnabled = state.IsEnabled, - RequiresRestart = state.RequiresRestart, - Status = status, - ErrorMessage = error - }); - } - catch (Exception ex) - { - logger.LogWarning(ex, "读取插件清单失败:文件={ManifestPath}", manifestPath); - } - } - - return plugins; - } - - private static void ConfigurePlugin( - IServiceCollection services, - PluginDescriptor descriptor, - PluginStateStore stateStore, - ILogger logger) - { - try - { - var plugin = CreatePlugin(descriptor); - if (!string.Equals(plugin.Id, descriptor.Id, StringComparison.Ordinal)) - throw new InvalidOperationException("Plugin entry id does not match manifest id."); - - var pluginInfo = CreatePluginInfo(descriptor); - Directory.CreateDirectory(pluginInfo.ConfigDirectory); - - var serviceCollection = new PluginServiceCollection(services, descriptor.Manifest); - var buildContext = new PluginBuildContext(descriptor.Manifest, pluginInfo); - plugin.ConfigureServices(serviceCollection, buildContext); - - services.AddSingleton(plugin); - services.AddSingleton(provider => - { - var loggerFactory = provider.GetRequiredService(); - var pluginLogger = loggerFactory.CreateLogger(GetPluginLogCategoryPrefix(descriptor.Id) + "Runtime"); - var drawInvoker = new PluginDrawInvoker( - descriptor.Id, - pluginLogger, - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService()); - var dataDirectory = pluginInfo.ConfigDirectory; - var runtimeContext = new PluginRuntimeContext(descriptor.Manifest, pluginInfo, pluginLogger, drawInvoker, dataDirectory); - return new LoadedPluginRegistration(plugin, runtimeContext); - }); - - StartupResults[descriptor.Id] = (PluginStatus.Loaded, null); - logger.LogInformation("插件已配置:插件={PluginId},版本={Version}", descriptor.Id, descriptor.Version); - } - catch (Exception ex) - { - StartupResults[descriptor.Id] = (PluginStatus.LoadFailed, ex.Message); - logger.LogError(ex, "插件配置失败:插件={PluginId}", descriptor.Id); - } - } - - private static ISecRandomPlugin CreatePlugin(PluginDescriptor descriptor) - { - var assemblyPath = Path.Combine(descriptor.DirectoryPath, descriptor.Manifest.EntryAssembly); - var assembly = Assembly.LoadFrom(assemblyPath); - var type = assembly.GetType(descriptor.Manifest.EntryType, true) - ?? throw new InvalidOperationException("Plugin entry type was not found."); - return Activator.CreateInstance(type) as ISecRandomPlugin - ?? throw new InvalidOperationException("Plugin entry type does not implement ISecRandomPlugin."); - } - - private static PluginInfo CreatePluginInfo(PluginDescriptor descriptor) - { - return new PluginInfo - { - Manifest = descriptor.Manifest, - PluginDirectory = descriptor.DirectoryPath, - ConfigDirectory = Utils.GetDirectoryPath("configs", "plugins", descriptor.Id) - }; - } - - private static string ResolveExtractedPluginDirectory(string extractDirectory) - { - var rootManifest = Path.Combine(extractDirectory, "plugin.json"); - if (File.Exists(rootManifest)) - return extractDirectory; - - var manifests = Directory.EnumerateFiles(extractDirectory, "plugin.json", SearchOption.AllDirectories) - .Where(path => IsPathUnderDirectory(extractDirectory, path)) - .ToList(); - - if (manifests.Count != 1) - throw new PluginImportException(PluginImportFailureReason.InvalidManifest, "Plugin package must contain exactly one plugin.json."); - - return Path.GetDirectoryName(manifests[0])!; - } - - private static bool IsPathUnderDirectory(string directory, string path) - { - var normalizedDirectory = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; - var normalizedPath = Path.GetFullPath(path); - return normalizedPath.StartsWith(normalizedDirectory, StringComparison.OrdinalIgnoreCase); - } - - private static PluginStatus ValidateManifest(PluginManifest manifest, string directoryPath, out string? error, bool allowMissingAssembly = false) - { - error = null; - if (string.IsNullOrWhiteSpace(manifest.Id)) - { - error = "插件 ID 不能为空。"; - return PluginStatus.LoadFailed; - } - - if (manifest.ApiVersion != SupportedApiVersion) - { - error = $"插件 API 版本不兼容:{manifest.ApiVersion}。"; - return PluginStatus.Incompatible; - } - - if (!string.IsNullOrWhiteSpace(manifest.MinimumHostVersion) && - Version.TryParse(manifest.MinimumHostVersion, out var minimumHostVersion) && - Version.TryParse(GlobalConstants.Version, out var hostVersion) && - hostVersion < minimumHostVersion) - { - error = $"插件需要 SecRandom {manifest.MinimumHostVersion} 或更高版本。"; - return PluginStatus.Incompatible; - } - - if (string.IsNullOrWhiteSpace(manifest.EntryAssembly)) - { - error = "插件入口程序集不能为空。"; - return PluginStatus.LoadFailed; - } - - if (string.IsNullOrWhiteSpace(manifest.EntryType)) - { - error = "插件入口类型不能为空。"; - return PluginStatus.LoadFailed; - } - - if (!allowMissingAssembly && !File.Exists(Path.Combine(directoryPath, manifest.EntryAssembly))) - { - error = "插件入口程序集不存在。"; - return PluginStatus.LoadFailed; - } - - return PluginStatus.Discovered; - } - - private static void DirectoryCopy(string sourceDirectory, string targetDirectory) - { - Directory.CreateDirectory(targetDirectory); - - foreach (var directory in Directory.EnumerateDirectories(sourceDirectory, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(sourceDirectory, directory); - Directory.CreateDirectory(Path.Combine(targetDirectory, relativePath)); - } - - foreach (var file in Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)) - { - var relativePath = Path.GetRelativePath(sourceDirectory, file); - var destination = Path.Combine(targetDirectory, relativePath); - Directory.CreateDirectory(Path.GetDirectoryName(destination)!); - File.Copy(file, destination, true); - } - } - - private static List ReadTailLines(string path, int maxLines) - { - using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - using var reader = new StreamReader(stream, Encoding.UTF8, true); - Queue lines = new(maxLines); - while (reader.ReadLine() is { } line) - { - if (lines.Count == maxLines) - lines.Dequeue(); - - lines.Enqueue(line); - } - - return lines.ToList(); - } - - private static IEnumerable ParseLogEntries(IReadOnlyList lines) - { - PluginLogEntryBuilder? current = null; - foreach (var line in lines) - { - var parts = line.Split('|', 4); - if (parts.Length == 4 && DateTime.TryParse(parts[0], out var time)) - { - if (current != null) - yield return current.Build(); - - current = new PluginLogEntryBuilder(time, parts[1], parts[2], parts[3]); - continue; - } - - current?.AppendLine(line); - } - - if (current != null) - yield return current.Build(); - } - - private sealed class PluginLogEntryBuilder(DateTime time, string level, string category, string message) - { - private readonly StringBuilder _message = new(message); - - public void AppendLine(string line) - { - _message.AppendLine(); - _message.Append(line); - } - - public PluginLogEntry Build() - { - return new PluginLogEntry(time, level, category, _message.ToString()); - } - } -} diff --git a/SecRandom/Services/Plugins/PluginRuntimeContext.cs b/SecRandom/Services/Plugins/PluginRuntimeContext.cs deleted file mode 100644 index 50e46ae2..00000000 --- a/SecRandom/Services/Plugins/PluginRuntimeContext.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.Extensions.Logging; -using SecRandom.Core.Plugins; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginRuntimeContext( - PluginManifest manifest, - PluginInfo pluginInfo, - ILogger logger, - IPluginDrawInvoker? drawInvoker, - string dataDirectory) : IPluginRuntimeContext -{ - public PluginManifest Manifest { get; } = manifest; - public PluginInfo PluginInfo { get; } = pluginInfo; - public ILogger Logger { get; } = logger; - public IPluginDrawInvoker? DrawInvoker { get; } = drawInvoker; - public string DataDirectory { get; } = dataDirectory; -} diff --git a/SecRandom/Services/Plugins/PluginSelectionState.cs b/SecRandom/Services/Plugins/PluginSelectionState.cs deleted file mode 100644 index 6eb0cf53..00000000 --- a/SecRandom/Services/Plugins/PluginSelectionState.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace SecRandom.Services.Plugins; - -public sealed class PluginSelectionState -{ - public string? SelectedPluginId { get; set; } -} diff --git a/SecRandom/Services/Plugins/PluginServiceCollection.cs b/SecRandom/Services/Plugins/PluginServiceCollection.cs deleted file mode 100644 index 770efd5c..00000000 --- a/SecRandom/Services/Plugins/PluginServiceCollection.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System; -using Microsoft.Extensions.DependencyInjection; -using SecRandom.Core.Extensions.Registry; -using SecRandom.Core.Plugins; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginServiceCollection( - IServiceCollection services, - PluginManifest manifest) : IPluginServiceCollection -{ - public void AddMainPage(PluginPageRegistration registration) - { - EnsurePluginOwnsRegistration(registration); - services.AddPluginMainPage(registration); - } - - public void AddSettingsPage(PluginPageRegistration registration) - { - EnsurePluginOwnsRegistration(registration); - services.AddPluginSettingsPage(registration); - } - - private void EnsurePluginOwnsRegistration(PluginPageRegistration registration) - { - if (!string.Equals(registration.PluginId, manifest.Id, StringComparison.Ordinal)) - throw new InvalidOperationException("Plugin page registration must use the current plugin id."); - } -} diff --git a/SecRandom/Services/Plugins/PluginStateStore.cs b/SecRandom/Services/Plugins/PluginStateStore.cs deleted file mode 100644 index 0ad0beef..00000000 --- a/SecRandom/Services/Plugins/PluginStateStore.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text.Json; -using System.Text.Json.Serialization; -using SecRandom.Shared; - -namespace SecRandom.Services.Plugins; - -public sealed class PluginStateStore -{ - private static readonly JsonSerializerOptions JsonOptions = new() - { - WriteIndented = true, - DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull - }; - - private readonly Dictionary _states = []; - - public PluginStateStore() - { - Load(); - ClearAppliedRestartFlags(); - } - - public IReadOnlyDictionary States => _states; - - public PluginState GetOrCreate(string pluginId) - { - if (_states.TryGetValue(pluginId, out var state)) - return state; - - state = new PluginState { PluginId = pluginId }; - _states[pluginId] = state; - return state; - } - - public void Save() - { - Directory.CreateDirectory(Path.GetDirectoryName(StateFilePath)!); - var states = _states.Values.OrderBy(x => x.PluginId, StringComparer.Ordinal).ToList(); - File.WriteAllText(StateFilePath, JsonSerializer.Serialize(states, JsonOptions)); - } - - private static string StateFilePath => Utils.GetFilePath("plugins", "plugins-state.json"); - - private void Load() - { - if (!File.Exists(StateFilePath)) - return; - - try - { - var states = JsonSerializer.Deserialize>(File.ReadAllText(StateFilePath), JsonOptions) ?? []; - foreach (var state in states.Where(x => !string.IsNullOrWhiteSpace(x.PluginId))) - _states[state.PluginId] = state; - } - catch - { - _states.Clear(); - } - } - - private void ClearAppliedRestartFlags() - { - var changed = false; - foreach (var state in _states.Values.Where(state => state.RequiresRestart)) - { - state.RequiresRestart = false; - changed = true; - } - - if (changed) - Save(); - } -} - -public sealed class PluginState -{ - public string PluginId { get; init; } = string.Empty; - public bool IsEnabled { get; set; } - public bool RequiresRestart { get; set; } -} diff --git a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs deleted file mode 100644 index 63d5a21d..00000000 --- a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs +++ /dev/null @@ -1,294 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net; -using System.Text; -using System.Text.Json; -using System.Text.Json.Nodes; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using SecRandom.Core.Abstraction.Services; -using SecRandom.Core.Enums; -using SecRandom.Core.Enums.Configs; -using SecRandom.Core.Models.Draw; -using SecRandom.Core.Services.Config; -using SecRandom.Core.Services.Draw; -using SecRandom.Services.Draw; -using SecRandom.Services.Linkage; -using SecRandom.Services.Notification; -using SecRandom.Services.Security; -using SecRandom.Shared.Extensions; -using SecRandom.Shared.Models.Profile; - -namespace SecRandom.Services.SecAgent; - -/// -/// Loopback-only REST endpoint for the local SecAgent connector. -/// SecRandom intentionally exposes ordinary HTTP/JSON here; tool discovery and hidden-tool -/// behavior belong to the SecAgent plugin. -/// -public sealed class SecAgentHttpHostedService( - ILogger logger, - IProfileService profileService, - MainConfigHandler configHandler, - IDrawTemporaryRecordService temporaryRecordService, - LinkageDrawCoordinator linkageDrawCoordinator, - NotificationService notificationService) : BackgroundService -{ - private const string Prefix = "http://127.0.0.1:3910/api/secagent/v1/"; - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - private readonly HttpListener _listener = new(); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _listener.Prefixes.Add(Prefix); - try - { - _listener.Start(); - logger.LogInformation("SecAgent loopback REST endpoint started at {Prefix}.", Prefix[..^1]); - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to start SecAgent loopback REST endpoint at {Prefix}.", Prefix); - return; - } - - try - { - while (!stoppingToken.IsCancellationRequested) - { - var context = await _listener.GetContextAsync().WaitAsync(stoppingToken).ConfigureAwait(false); - _ = Task.Run(() => HandleAsync(context, stoppingToken), CancellationToken.None); - } - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - } - catch (HttpListenerException) when (stoppingToken.IsCancellationRequested) - { - } - finally - { - _listener.Stop(); - _listener.Close(); - } - } - - public override Task StopAsync(CancellationToken cancellationToken) - { - if (_listener.IsListening) - _listener.Stop(); - return base.StopAsync(cancellationToken); - } - - private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) - { - try - { - var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty; - var method = context.Request.HttpMethod.ToUpperInvariant(); - JsonNode result; - - if (method == "GET" && path == "/api/secagent/v1/students") - result = ListStudents(); - else if (method == "POST" && path == "/api/secagent/v1/students") - result = UpsertStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); - else if (method == "DELETE" && path == "/api/secagent/v1/students") - result = RemoveStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); - else if (method == "POST" && path == "/api/secagent/v1/draw/students") - result = await DrawStudentsAsync(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - else - { - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - result = new JsonObject { ["error"] = "Endpoint not found." }; - } - - await WriteJsonAsync(context.Response, result, cancellationToken).ConfigureAwait(false); - } - catch (ArgumentException ex) - { - await WriteErrorAsync(context.Response, HttpStatusCode.BadRequest, ex.Message).ConfigureAwait(false); - } - catch (InvalidOperationException ex) - { - await WriteErrorAsync(context.Response, HttpStatusCode.Conflict, ex.Message).ConfigureAwait(false); - } - catch (Exception ex) - { - logger.LogWarning(ex, "SecAgent REST request failed."); - await WriteErrorAsync(context.Response, HttpStatusCode.InternalServerError, "SecRandom request failed.").ConfigureAwait(false); - } - finally - { - context.Response.Close(); - } - } - - private JsonObject ListStudents() - { - var list = profileService.CurrentStudentList; - return new JsonObject - { - ["profile"] = list?.Name ?? string.Empty, - ["students"] = new JsonArray((list?.Students ?? []).Select(ToJson).ToArray()) - }; - } - - private JsonObject UpsertStudent(JsonObject arguments) - { - var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); - var recordId = ParseGuid(arguments["record_id"]?.GetValue()); - var id = StringArgument(arguments, "id"); - var student = recordId is not null ? list.Students.FirstOrDefault(item => item.RecordId == recordId) : null; - student ??= !string.IsNullOrWhiteSpace(id) ? list.Students.FirstOrDefault(item => item.Id == id) : null; - if (student is null) - { - student = new Student { RecordId = recordId ?? Guid.NewGuid() }; - list.Students.Add(student); - } - - student.Id = id; - student.Name = StringArgument(arguments, "name"); - student.Group = StringArgument(arguments, "group"); - student.Gender = StringArgument(arguments, "gender"); - student.Tags = StringArgument(arguments, "tags"); - student.Exists = arguments["exists"]?.GetValue() ?? true; - if (!student.IsCandidate) - throw new ArgumentException("Student requires a nonblank id or name."); - profileService.SaveProfile(); - return new JsonObject { ["student"] = ToJson(student), ["profile"] = list.Name }; - } - - private JsonObject RemoveStudent(JsonObject arguments) - { - var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); - var recordId = ParseGuid(arguments["record_id"]?.GetValue()); - var id = StringArgument(arguments, "id"); - var name = StringArgument(arguments, "name"); - var matches = list.Students.Where(item => - (recordId is not null && item.RecordId == recordId) - || (!string.IsNullOrWhiteSpace(id) && item.Id == id) - || (!string.IsNullOrWhiteSpace(name) && item.Name == name)).ToList(); - if (matches.Count != 1) - throw new InvalidOperationException(matches.Count == 0 ? "Student was not found." : "Student selector matched more than one student."); - list.Students.Remove(matches[0]); - profileService.SaveProfile(); - return new JsonObject { ["removed"] = ToJson(matches[0]), ["profile"] = list.Name }; - } - - private async Task DrawStudentsAsync(JsonObject arguments, CancellationToken cancellationToken) - { - var mode = StringArgument(arguments, "mode"); - if (mode is not ("flash" or "result_only")) - throw new ArgumentException("mode must be flash or result_only."); - - var requestedCount = Math.Clamp(arguments["count"]?.GetValue() ?? 1, 1, 100); - if (mode == "flash") requestedCount = 1; - var includeTags = StringArray(arguments, "include_tags"); - var excludeTags = StringArray(arguments, "exclude_tags"); - var includeIds = StringArray(arguments, "include_ids"); - var includeNames = StringArray(arguments, "include_names"); - var listName = profileService.CurrentStudentList?.Name ?? string.Empty; - var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, string.Empty, string.Empty); - - var result = await InvokeAuthorizedAsync(SecurityOperation.QuickDrawStart, () => - { - var engine = new DrawEngine(); - var draw = engine.DrawStudent(requestedCount, student => Matches(student, includeTags, excludeTags, includeIds, includeNames) - && !HasReachedTemporaryLimit(student, temporaryCounts), DrawSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName()); - if (!draw.IsSuccess || draw.Result.Count == 0) - return Task.FromResult(draw); - - profileService.RecordStudentHistory(draw.Result, DateTime.Now, requestedCount, - drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType, - courseName: linkageDrawCoordinator.GetCourseName()); - temporaryRecordService.RecordStudents(listName, string.Empty, string.Empty, draw.Result); - if (mode == "flash") - notificationService.QueueStudents(NotificationSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName(), draw.Result); - return Task.FromResult(draw); - }, cancellationToken).ConfigureAwait(false); - - return new JsonObject - { - ["mode"] = mode, - ["count"] = result.Result.Count, - ["status"] = result.Status.ToString(), - ["profile"] = listName, - ["students"] = new JsonArray(result.Result.Select(ToJson).ToArray()) - }; - } - - private async Task> InvokeAuthorizedAsync(SecurityOperation operation, Func>> action, CancellationToken cancellationToken) - { - DrawResult? result = null; - var authorized = await linkageDrawCoordinator.AuthorizeAsync(operation, - async () => result = await action().ConfigureAwait(false), cancellationToken).ConfigureAwait(false); - return authorized && result is not null ? result : new DrawResult { Status = DrawStatus.Failure }; - } - - private bool HasReachedTemporaryLimit(Student student, IReadOnlyDictionary temporaryCounts) - { - var settings = configHandler.Data.QuickDrawSettings; - var threshold = settings.DrawMode switch - { - DrawMode.Repeat => 0, - DrawMode.NoRepeat => 1, - DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat), - _ => 1 - }; - return threshold > 0 && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold; - } - - private static bool Matches(Student student, IReadOnlyCollection includeTags, IReadOnlyCollection excludeTags, - IReadOnlyCollection includeIds, IReadOnlyCollection includeNames) - { - var tags = student.Tags.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - return includeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) - && excludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) - && (includeIds.Count == 0 || includeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase)) - && (includeNames.Count == 0 || includeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase)); - } - - private static JsonObject ToJson(Student student) => new() - { - ["record_id"] = ProfileRecordIdentity.EnsureRecordId(student), - ["id"] = student.Id, - ["name"] = student.Name, - ["group"] = student.Group, - ["gender"] = student.Gender, - ["tags"] = student.Tags, - ["exists"] = student.Exists - }; - - private static async Task ReadBodyAsync(HttpListenerRequest request, CancellationToken cancellationToken) - { - var body = await JsonNode.ParseAsync(request.InputStream, cancellationToken: cancellationToken).ConfigureAwait(false) as JsonObject; - return body ?? throw new ArgumentException("Request body must be a JSON object."); - } - - private static string StringArgument(JsonObject arguments, string name) => arguments[name]?.GetValue()?.Trim() ?? string.Empty; - private static Guid? ParseGuid(string? value) => Guid.TryParse(value, out var result) ? result : null; - - private static IReadOnlyList StringArray(JsonObject arguments, string name) - => arguments[name] is JsonArray array - ? array.Select(item => item?.GetValue()?.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Cast().ToArray() - : []; - - private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode value, CancellationToken cancellationToken) - { - var bytes = Encoding.UTF8.GetBytes(value.ToJsonString(JsonOptions)); - response.ContentType = "application/json"; - response.ContentEncoding = Encoding.UTF8; - response.ContentLength64 = bytes.Length; - await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); - } - - private static Task WriteErrorAsync(HttpListenerResponse response, HttpStatusCode status, string message) - { - response.StatusCode = (int)status; - return WriteJsonAsync(response, new JsonObject { ["error"] = message }, CancellationToken.None); - } -} diff --git a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs deleted file mode 100644 index daa45dcc..00000000 --- a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace SecRandom.Services.SecAgent; - -/// -/// Quietly asks a running local SecAgent to install the SecRandom connector. -/// This is deliberately best-effort: SecRandom remains fully usable without SecAgent. -/// -public sealed class SecAgentPluginBootstrapHostedService( - IHttpClientFactory httpClientFactory, - ILogger logger) : BackgroundService -{ - private const string ConnectorPluginId = "secrandom"; - private const string ConnectorPluginVersion = "0.1.1"; - private static readonly Uri BaseUri = new("http://127.0.0.1:42189/"); - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - // Give the desktop host time to finish its own startup, and never hold up the UI. - try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false); } - catch (OperationCanceledException) { return; } - - while (!stoppingToken.IsCancellationRequested) - { - try - { - await EnsurePluginAsync(stoppingToken).ConfigureAwait(false); - return; - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - return; - } - catch (HttpRequestException) - { - // SecAgent is optional and may simply not be installed/running. - } - catch (Exception ex) - { - logger.LogDebug(ex, "SecAgent connector bootstrap was skipped."); - return; - } - - try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); } - catch (OperationCanceledException) { return; } - } - } - - private async Task EnsurePluginAsync(CancellationToken cancellationToken) - { - using var client = httpClientFactory.CreateClient(); - client.BaseAddress = BaseUri; - client.Timeout = TimeSpan.FromSeconds(2); - - using var health = await client.GetAsync("health", cancellationToken).ConfigureAwait(false); - if (!health.IsSuccessStatusCode) return; - - var installed = await client.GetFromJsonAsync("plugins", JsonOptions, cancellationToken).ConfigureAwait(false); - var current = installed?.Plugins?.FirstOrDefault(plugin => - string.Equals(plugin.Id, ConnectorPluginId, StringComparison.OrdinalIgnoreCase)); - if (current is not null && !IsOlderVersion(current.Version, ConnectorPluginVersion)) - return; - - var request = current is null - ? new { pluginId = ConnectorPluginId, version = (string?)null } - : new { pluginId = ConnectorPluginId, version = (string?)ConnectorPluginVersion }; - using var response = await client.PostAsJsonAsync("plugins/install", request, JsonOptions, cancellationToken).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - logger.LogInformation("Requested local SecAgent to install/update the SecRandom connector plugin to {Version}.", ConnectorPluginVersion); - else - logger.LogDebug("Local SecAgent declined SecRandom connector installation with HTTP {StatusCode}.", response.StatusCode); - } - - private sealed class PluginListResponse - { - public List? Plugins { get; init; } - } - - private sealed class PluginInfo - { - public string? Id { get; init; } - public string? Version { get; init; } - } - - private static bool IsOlderVersion(string? current, string desired) - { - if (Version.TryParse(current, out var currentVersion) && Version.TryParse(desired, out var desiredVersion)) - return currentVersion < desiredVersion; - - return !string.Equals(current, desired, StringComparison.OrdinalIgnoreCase); - } -} From fdbf50128d66ed21703e53be68d617808aad00d1 Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 13:57:06 +0800 Subject: [PATCH 5/7] chore: remove SecAgent bootstrap from merge --- SecRandom/App.axaml.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 3b55ae49..91bc7f20 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -56,7 +56,6 @@ using SecRandom.Services.Music; using SecRandom.Services.Settings; using SecRandom.Services.Security; -using SecRandom.Services.SecAgent; using SecRandom.Services.Telemetry; using SecRandom.Services.Verification; using SecRandom.Services.Voice; @@ -775,9 +774,6 @@ private void BuildHost(IPlatformServiceRoot platform) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - // Local-only REST endpoint for the SecAgent connector. It intentionally has no UI/settings registration. - services.AddHostedService(); - services.AddHostedService(); services.AddSingleton(serviceProvider => new MusicLibraryService( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService>(), From f7fbde55f229bc2640aa88b3d00f4d3de78907a3 Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 14:22:45 +0800 Subject: [PATCH 6/7] fix: restore SecAgent HTTP integration --- SecRandom/App.axaml.cs | 4 + .../SecAgent/SecAgentHttpHostedService.cs | 294 ++++++++++++++++++ .../SecAgentPluginBootstrapHostedService.cs | 102 ++++++ 3 files changed, 400 insertions(+) create mode 100644 SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs create mode 100644 SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 91bc7f20..3b55ae49 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -56,6 +56,7 @@ using SecRandom.Services.Music; using SecRandom.Services.Settings; using SecRandom.Services.Security; +using SecRandom.Services.SecAgent; using SecRandom.Services.Telemetry; using SecRandom.Services.Verification; using SecRandom.Services.Voice; @@ -774,6 +775,9 @@ private void BuildHost(IPlatformServiceRoot platform) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + // Local-only REST endpoint for the SecAgent connector. It intentionally has no UI/settings registration. + services.AddHostedService(); + services.AddHostedService(); services.AddSingleton(serviceProvider => new MusicLibraryService( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService>(), diff --git a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs new file mode 100644 index 00000000..3863c987 --- /dev/null +++ b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using SecRandom.Core.Abstraction.Services; +using SecRandom.Core.Enums; +using SecRandom.Core.Enums.Configs; +using SecRandom.Core.Models.Draw; +using SecRandom.Core.Services.Config; +using SecRandom.Core.Services.Draw; +using SecRandom.Services.Draw; +using SecRandom.Services.Linkage; +using SecRandom.Services.Notification; +using SecRandom.Services.Security; +using SecRandom.Shared.Extensions; +using SecRandom.Shared.Models.Profile; + +namespace SecRandom.Services.SecAgent; + +/// +/// Loopback-only REST endpoint for the local SecAgent connector. +/// SecRandom intentionally exposes ordinary HTTP/JSON here; tool discovery and hidden-tool +/// behavior belong to the SecAgent plugin. +/// +public sealed class SecAgentHttpHostedService( + ILogger logger, + IProfileService profileService, + MainConfigHandler configHandler, + IDrawTemporaryRecordService temporaryRecordService, + DrawEngine drawEngine, + LinkageDrawCoordinator linkageDrawCoordinator, + NotificationService notificationService) : BackgroundService +{ + private const string Prefix = "http://127.0.0.1:3910/api/secagent/v1/"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly HttpListener _listener = new(); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _listener.Prefixes.Add(Prefix); + try + { + _listener.Start(); + logger.LogInformation("SecAgent loopback REST endpoint started at {Prefix}.", Prefix[..^1]); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to start SecAgent loopback REST endpoint at {Prefix}.", Prefix); + return; + } + + try + { + while (!stoppingToken.IsCancellationRequested) + { + var context = await _listener.GetContextAsync().WaitAsync(stoppingToken).ConfigureAwait(false); + _ = Task.Run(() => HandleAsync(context, stoppingToken), CancellationToken.None); + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (HttpListenerException) when (stoppingToken.IsCancellationRequested) + { + } + finally + { + _listener.Stop(); + _listener.Close(); + } + } + + public override Task StopAsync(CancellationToken cancellationToken) + { + if (_listener.IsListening) + _listener.Stop(); + return base.StopAsync(cancellationToken); + } + + private async Task HandleAsync(HttpListenerContext context, CancellationToken cancellationToken) + { + try + { + var path = context.Request.Url?.AbsolutePath.TrimEnd('/') ?? string.Empty; + var method = context.Request.HttpMethod.ToUpperInvariant(); + JsonNode result; + + if (method == "GET" && path == "/api/secagent/v1/students") + result = ListStudents(); + else if (method == "POST" && path == "/api/secagent/v1/students") + result = UpsertStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); + else if (method == "DELETE" && path == "/api/secagent/v1/students") + result = RemoveStudent(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false)); + else if (method == "POST" && path == "/api/secagent/v1/draw/students") + result = await DrawStudentsAsync(await ReadBodyAsync(context.Request, cancellationToken).ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + else + { + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + result = new JsonObject { ["error"] = "Endpoint not found." }; + } + + await WriteJsonAsync(context.Response, result, cancellationToken).ConfigureAwait(false); + } + catch (ArgumentException ex) + { + await WriteErrorAsync(context.Response, HttpStatusCode.BadRequest, ex.Message).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + await WriteErrorAsync(context.Response, HttpStatusCode.Conflict, ex.Message).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogWarning(ex, "SecAgent REST request failed."); + await WriteErrorAsync(context.Response, HttpStatusCode.InternalServerError, "SecRandom request failed.").ConfigureAwait(false); + } + finally + { + context.Response.Close(); + } + } + + private JsonObject ListStudents() + { + var list = profileService.CurrentStudentList; + return new JsonObject + { + ["profile"] = list?.Name ?? string.Empty, + ["students"] = new JsonArray((list?.Students ?? []).Select(ToJson).ToArray()) + }; + } + + private JsonObject UpsertStudent(JsonObject arguments) + { + var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); + var recordId = ParseGuid(arguments["record_id"]?.GetValue()); + var id = StringArgument(arguments, "id"); + var student = recordId is not null ? list.Students.FirstOrDefault(item => item.RecordId == recordId) : null; + student ??= !string.IsNullOrWhiteSpace(id) ? list.Students.FirstOrDefault(item => item.Id == id) : null; + if (student is null) + { + student = new Student { RecordId = recordId ?? Guid.NewGuid() }; + list.Students.Add(student); + } + + student.Id = id; + student.Name = StringArgument(arguments, "name"); + student.Group = StringArgument(arguments, "group"); + student.Gender = StringArgument(arguments, "gender"); + student.Tags = StringArgument(arguments, "tags"); + student.Exists = arguments["exists"]?.GetValue() ?? true; + if (!student.IsCandidate) + throw new ArgumentException("Student requires a nonblank id or name."); + profileService.SaveProfile(); + return new JsonObject { ["student"] = ToJson(student), ["profile"] = list.Name }; + } + + private JsonObject RemoveStudent(JsonObject arguments) + { + var list = profileService.CurrentStudentList ?? throw new InvalidOperationException("No current student profile."); + var recordId = ParseGuid(arguments["record_id"]?.GetValue()); + var id = StringArgument(arguments, "id"); + var name = StringArgument(arguments, "name"); + var matches = list.Students.Where(item => + (recordId is not null && item.RecordId == recordId) + || (!string.IsNullOrWhiteSpace(id) && item.Id == id) + || (!string.IsNullOrWhiteSpace(name) && item.Name == name)).ToList(); + if (matches.Count != 1) + throw new InvalidOperationException(matches.Count == 0 ? "Student was not found." : "Student selector matched more than one student."); + list.Students.Remove(matches[0]); + profileService.SaveProfile(); + return new JsonObject { ["removed"] = ToJson(matches[0]), ["profile"] = list.Name }; + } + + private async Task DrawStudentsAsync(JsonObject arguments, CancellationToken cancellationToken) + { + var mode = StringArgument(arguments, "mode"); + if (mode is not ("flash" or "result_only")) + throw new ArgumentException("mode must be flash or result_only."); + + var requestedCount = Math.Clamp(arguments["count"]?.GetValue() ?? 1, 1, 100); + if (mode == "flash") requestedCount = 1; + var includeTags = StringArray(arguments, "include_tags"); + var excludeTags = StringArray(arguments, "exclude_tags"); + var includeIds = StringArray(arguments, "include_ids"); + var includeNames = StringArray(arguments, "include_names"); + var listName = profileService.CurrentStudentList?.Name ?? string.Empty; + var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, string.Empty, string.Empty); + + var result = await InvokeAuthorizedAsync(SecurityOperation.QuickDrawStart, () => + { + var draw = drawEngine.DrawStudent(requestedCount, student => Matches(student, includeTags, excludeTags, includeIds, includeNames) + && !HasReachedTemporaryLimit(student, temporaryCounts), DrawSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName()); + if (!draw.IsSuccess || draw.Result.Count == 0) + return Task.FromResult(draw); + + profileService.RecordStudentHistory(draw.Result, DateTime.Now, requestedCount, + drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType, + courseName: linkageDrawCoordinator.GetCourseName()); + temporaryRecordService.RecordStudents(listName, string.Empty, string.Empty, draw.Result); + if (mode == "flash") + notificationService.QueueStudents(NotificationSettingsType.QuickDraw, linkageDrawCoordinator.GetCourseName(), draw.Result); + return Task.FromResult(draw); + }, cancellationToken).ConfigureAwait(false); + + return new JsonObject + { + ["mode"] = mode, + ["count"] = result.Result.Count, + ["status"] = result.Status.ToString(), + ["profile"] = listName, + ["students"] = new JsonArray(result.Result.Select(ToJson).ToArray()) + }; + } + + private async Task> InvokeAuthorizedAsync(SecurityOperation operation, Func>> action, CancellationToken cancellationToken) + { + DrawResult? result = null; + var authorized = await linkageDrawCoordinator.AuthorizeAsync(operation, + async () => result = await action().ConfigureAwait(false), cancellationToken).ConfigureAwait(false); + return authorized && result is not null ? result : new DrawResult { Status = DrawStatus.Failure }; + } + + private bool HasReachedTemporaryLimit(Student student, IReadOnlyDictionary temporaryCounts) + { + var settings = configHandler.Data.QuickDrawSettings; + var threshold = settings.DrawMode switch + { + DrawMode.Repeat => 0, + DrawMode.NoRepeat => 1, + DrawMode.HalfRepeat => Math.Max(1, settings.HalfRepeat), + _ => 1 + }; + return threshold > 0 && temporaryCounts.GetValueOrDefault(ProfileRecordIdentity.EnsureRecordId(student)) >= threshold; + } + + private static bool Matches(Student student, IReadOnlyCollection includeTags, IReadOnlyCollection excludeTags, + IReadOnlyCollection includeIds, IReadOnlyCollection includeNames) + { + var tags = student.Tags.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return includeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && excludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && (includeIds.Count == 0 || includeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase)) + && (includeNames.Count == 0 || includeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase)); + } + + private static JsonObject ToJson(Student student) => new() + { + ["record_id"] = ProfileRecordIdentity.EnsureRecordId(student), + ["id"] = student.Id, + ["name"] = student.Name, + ["group"] = student.Group, + ["gender"] = student.Gender, + ["tags"] = student.Tags, + ["exists"] = student.Exists + }; + + private static async Task ReadBodyAsync(HttpListenerRequest request, CancellationToken cancellationToken) + { + var body = await JsonNode.ParseAsync(request.InputStream, cancellationToken: cancellationToken).ConfigureAwait(false) as JsonObject; + return body ?? throw new ArgumentException("Request body must be a JSON object."); + } + + private static string StringArgument(JsonObject arguments, string name) => arguments[name]?.GetValue()?.Trim() ?? string.Empty; + private static Guid? ParseGuid(string? value) => Guid.TryParse(value, out var result) ? result : null; + + private static IReadOnlyList StringArray(JsonObject arguments, string name) + => arguments[name] is JsonArray array + ? array.Select(item => item?.GetValue()?.Trim()).Where(item => !string.IsNullOrWhiteSpace(item)).Cast().ToArray() + : []; + + private static async Task WriteJsonAsync(HttpListenerResponse response, JsonNode value, CancellationToken cancellationToken) + { + var bytes = Encoding.UTF8.GetBytes(value.ToJsonString(JsonOptions)); + response.ContentType = "application/json"; + response.ContentEncoding = Encoding.UTF8; + response.ContentLength64 = bytes.Length; + await response.OutputStream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false); + } + + private static Task WriteErrorAsync(HttpListenerResponse response, HttpStatusCode status, string message) + { + response.StatusCode = (int)status; + return WriteJsonAsync(response, new JsonObject { ["error"] = message }, CancellationToken.None); + } +} diff --git a/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs new file mode 100644 index 00000000..daa45dcc --- /dev/null +++ b/SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace SecRandom.Services.SecAgent; + +/// +/// Quietly asks a running local SecAgent to install the SecRandom connector. +/// This is deliberately best-effort: SecRandom remains fully usable without SecAgent. +/// +public sealed class SecAgentPluginBootstrapHostedService( + IHttpClientFactory httpClientFactory, + ILogger logger) : BackgroundService +{ + private const string ConnectorPluginId = "secrandom"; + private const string ConnectorPluginVersion = "0.1.1"; + private static readonly Uri BaseUri = new("http://127.0.0.1:42189/"); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // Give the desktop host time to finish its own startup, and never hold up the UI. + try { await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EnsurePluginAsync(stoppingToken).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (HttpRequestException) + { + // SecAgent is optional and may simply not be installed/running. + } + catch (Exception ex) + { + logger.LogDebug(ex, "SecAgent connector bootstrap was skipped."); + return; + } + + try { await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken).ConfigureAwait(false); } + catch (OperationCanceledException) { return; } + } + } + + private async Task EnsurePluginAsync(CancellationToken cancellationToken) + { + using var client = httpClientFactory.CreateClient(); + client.BaseAddress = BaseUri; + client.Timeout = TimeSpan.FromSeconds(2); + + using var health = await client.GetAsync("health", cancellationToken).ConfigureAwait(false); + if (!health.IsSuccessStatusCode) return; + + var installed = await client.GetFromJsonAsync("plugins", JsonOptions, cancellationToken).ConfigureAwait(false); + var current = installed?.Plugins?.FirstOrDefault(plugin => + string.Equals(plugin.Id, ConnectorPluginId, StringComparison.OrdinalIgnoreCase)); + if (current is not null && !IsOlderVersion(current.Version, ConnectorPluginVersion)) + return; + + var request = current is null + ? new { pluginId = ConnectorPluginId, version = (string?)null } + : new { pluginId = ConnectorPluginId, version = (string?)ConnectorPluginVersion }; + using var response = await client.PostAsJsonAsync("plugins/install", request, JsonOptions, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + logger.LogInformation("Requested local SecAgent to install/update the SecRandom connector plugin to {Version}.", ConnectorPluginVersion); + else + logger.LogDebug("Local SecAgent declined SecRandom connector installation with HTTP {StatusCode}.", response.StatusCode); + } + + private sealed class PluginListResponse + { + public List? Plugins { get; init; } + } + + private sealed class PluginInfo + { + public string? Id { get; init; } + public string? Version { get; init; } + } + + private static bool IsOlderVersion(string? current, string desired) + { + if (Version.TryParse(current, out var currentVersion) && Version.TryParse(desired, out var desiredVersion)) + return currentVersion < desiredVersion; + + return !string.Equals(current, desired, StringComparison.OrdinalIgnoreCase); + } +} From 50ab074c335ff10cbc7610f6b84caa30a469217f Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Sun, 9 Aug 2026 14:30:47 +0800 Subject: [PATCH 7/7] chore: keep PR focused on SecAgent integration --- SecRandom.Core/Services/Draw/DrawEngine.cs | 23 ++-------------------- SecRandom/Views/FirstRunOobeWindow.axaml | 7 ++----- 2 files changed, 4 insertions(+), 26 deletions(-) diff --git a/SecRandom.Core/Services/Draw/DrawEngine.cs b/SecRandom.Core/Services/Draw/DrawEngine.cs index 70aace10..a91dd5f8 100644 --- a/SecRandom.Core/Services/Draw/DrawEngine.cs +++ b/SecRandom.Core/Services/Draw/DrawEngine.cs @@ -53,19 +53,9 @@ public DrawResult DrawStudent( DrawSettingsType drawSettingsType, string courseName = "") { - var executionPolicy = StudentDrawExecutionPolicy.DesktopConfigured( + return DrawStudent(count, filter, drawSettingsType, StudentDrawExecutionPolicy.DesktopConfigured( GetStudentDrawType(drawSettingsType), - ConfigData.FairDrawSettings); - var result = DrawStudent(count, filter, drawSettingsType, executionPolicy, courseName); - if (result.Status != DrawStatus.RepeatLimitExhausted - || !ShouldAutoResetStudentRound(count, filter, drawSettingsType)) - return result; - - var listName = StudentList.Name; - _profileService.ClearCurrentStudentHistory(); - IAppHost.TryGetService()?.ClearStudentList(listName); - _logger.LogInformation("学生抽取已完成一轮,已自动清空名单 {StudentListName} 的历史和临时记录并开始新一轮。", listName); - return DrawStudent(count, filter, drawSettingsType, executionPolicy, courseName); + ConfigData.FairDrawSettings), courseName); } internal DrawResult DrawStudent( @@ -123,15 +113,6 @@ bool Filter1(Student student) } } - private bool ShouldAutoResetStudentRound(int count, Func filter, DrawSettingsType drawSettingsType) - { - if (count <= 0 || GetStudentRepeatThreshold(drawSettingsType) <= 0) - return false; - - var baseCandidateCount = StudentList.Students.Count(student => student.IsCandidate && filter(student)); - return baseCandidateCount >= count; - } - public DrawResult DrawStudent(int count, IReadOnlyCollection candidates, string courseName = "") { var candidateSet = candidates as HashSet ?? candidates.ToHashSet(); diff --git a/SecRandom/Views/FirstRunOobeWindow.axaml b/SecRandom/Views/FirstRunOobeWindow.axaml index 2d87e043..845c6eaa 100644 --- a/SecRandom/Views/FirstRunOobeWindow.axaml +++ b/SecRandom/Views/FirstRunOobeWindow.axaml @@ -60,10 +60,7 @@ - - + - + \ No newline at end of file