From f0b75dcab5c8b34c55e51126e6290e02fd2defd5 Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Mon, 17 Aug 2026 09:07:21 +0800 Subject: [PATCH 1/3] refactor: extract SecAgent support into plugin boundary --- .../IExternalStudentDrawService.cs | 34 ++ SecRandom.PluginSdk/README.md | 6 + SecRandom/App.axaml.cs | 5 +- .../Draw/ExternalStudentDrawService.cs | 131 ++++++++ .../SecAgent/SecAgentHttpHostedService.cs | 294 ------------------ .../SecAgentPluginBootstrapHostedService.cs | 102 ------ 6 files changed, 172 insertions(+), 400 deletions(-) create mode 100644 SecRandom.PluginSdk/IExternalStudentDrawService.cs create mode 100644 SecRandom/Services/Draw/ExternalStudentDrawService.cs delete mode 100644 SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs delete mode 100644 SecRandom/Services/SecAgent/SecAgentPluginBootstrapHostedService.cs diff --git a/SecRandom.PluginSdk/IExternalStudentDrawService.cs b/SecRandom.PluginSdk/IExternalStudentDrawService.cs new file mode 100644 index 00000000..a334807f --- /dev/null +++ b/SecRandom.PluginSdk/IExternalStudentDrawService.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using SecRandom.Shared.Models.Profile; + +namespace SecRandom.PluginSdk; + +/// +/// Host-neutral student draw boundary for plugins and other external integrations. +/// The transport and authentication policy belong to the integration that consumes it. +/// +public sealed record ExternalStudentDrawRequest +{ + public string Mode { get; init; } = "result_only"; + public int Count { get; init; } = 1; + public string Gender { get; init; } = string.Empty; + public IReadOnlyList IncludeTags { get; init; } = []; + public IReadOnlyList ExcludeTags { get; init; } = []; + public IReadOnlyList IncludeIds { get; init; } = []; + public IReadOnlyList IncludeNames { get; init; } = []; +} + +public sealed record ExternalStudentDrawResult( + string Mode, + string Status, + string Profile, + IReadOnlyList Students); + +public interface IExternalStudentDrawService +{ + Task DrawAsync( + ExternalStudentDrawRequest request, + CancellationToken cancellationToken = default); +} diff --git a/SecRandom.PluginSdk/README.md b/SecRandom.PluginSdk/README.md index b464f5a2..5ce705cd 100644 --- a/SecRandom.PluginSdk/README.md +++ b/SecRandom.PluginSdk/README.md @@ -32,6 +32,12 @@ Published plugins reference the SDK package and exclude its runtime assets so th The repository template defaults `UseLocalPluginSdk=true` so solution builds work before the SDK is published; set `UseLocalPluginSdk=false` with a NuGet source that contains `SecRandom.PluginSdk` to exercise the release packaging path. +## Host integration APIs + +`IExternalStudentDrawService` lets a transport plugin request a host-managed student draw +without depending on desktop application's internal service types. The host owns drawing, +temporary records, history, security, and notifications; the plugin owns its transport. + ## Building a plugin package Set `true` to produce `srpx/.srpx` after every build. The package is a ZIP whose root contains `manifest.yml`, the entrance assembly, and any external package dependencies. Place it in `data/cache/plugin-packages` and restart the desktop application to install. diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 39f5124c..97bae73b 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -59,7 +59,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; @@ -844,9 +843,7 @@ private void BuildHost(IPlatformServiceRoot platform) services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); services.AddSingleton(); - // Local-only REST endpoint for the SecAgent connector. It intentionally has no UI/settings registration. - services.AddHostedService(); - services.AddHostedService(); + services.AddSingleton(); services.AddSingleton(serviceProvider => new MusicLibraryService( serviceProvider.GetRequiredService(), serviceProvider.GetRequiredService>(), diff --git a/SecRandom/Services/Draw/ExternalStudentDrawService.cs b/SecRandom/Services/Draw/ExternalStudentDrawService.cs new file mode 100644 index 00000000..c6368729 --- /dev/null +++ b/SecRandom/Services/Draw/ExternalStudentDrawService.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +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.PluginSdk; +using SecRandom.Services.Linkage; +using SecRandom.Services.Notification; +using SecRandom.Shared.Extensions; +using SecRandom.Shared.Models.Profile; + +namespace SecRandom.Services.Draw; + +/// +/// Implements the host-neutral external student draw boundary. +/// Transport-specific integrations, such as the SecAgent plugin, call this service. +/// +public sealed class ExternalStudentDrawService( + IProfileService profileService, + MainConfigHandler configHandler, + IDrawTemporaryRecordService temporaryRecordService, + DrawEngine drawEngine, + LinkageDrawCoordinator linkageDrawCoordinator, + NotificationService notificationService) : IExternalStudentDrawService +{ + public async Task DrawAsync( + ExternalStudentDrawRequest request, + CancellationToken cancellationToken = default) + { + if (request.Mode is not ("flash" or "result_only")) + throw new ArgumentException("mode must be flash or result_only.", nameof(request)); + + var requestedCount = Math.Clamp(request.Count, 1, 100); + if (request.Mode == "flash") + requestedCount = 1; + + var gender = request.Gender?.Trim() ?? string.Empty; + var listName = profileService.CurrentStudentList?.Name ?? string.Empty; + var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, gender, string.Empty); + + var result = await InvokeAuthorizedAsync( + SecurityOperation.QuickDrawStart, + async () => + { + var draw = drawEngine.DrawStudent( + requestedCount, + student => Matches(student, gender, request) + && !HasReachedTemporaryLimit(student, temporaryCounts), + DrawSettingsType.QuickDraw, + linkageDrawCoordinator.GetCourseName()); + if (!draw.IsSuccess || draw.Result.Count == 0) + return draw; + + profileService.RecordStudentHistory( + draw.Result, + DateTime.Now, + requestedCount, + drawMethod: (int)configHandler.Data.QuickDrawSettings.DrawType, + courseName: linkageDrawCoordinator.GetCourseName()); + temporaryRecordService.RecordStudents(listName, gender, string.Empty, draw.Result); + if (request.Mode == "flash") + notificationService.QueueStudents( + NotificationSettingsType.QuickDraw, + linkageDrawCoordinator.GetCourseName(), + draw.Result); + return draw; + }, + cancellationToken).ConfigureAwait(false); + + return new ExternalStudentDrawResult( + request.Mode, + result.Status.ToString(), + listName, + result.Result); + } + + 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, + string gender, + ExternalStudentDrawRequest request) + { + var tags = student.Tags.Split( + [',', ';', ' '], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return (string.IsNullOrWhiteSpace(gender) + || string.Equals(student.Gender, gender, StringComparison.OrdinalIgnoreCase)) + && request.IncludeTags.All(tag => tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && request.ExcludeTags.All(tag => !tags.Contains(tag, StringComparer.OrdinalIgnoreCase)) + && (request.IncludeIds.Count == 0 + || request.IncludeIds.Contains(student.Id, StringComparer.OrdinalIgnoreCase)) + && (request.IncludeNames.Count == 0 + || request.IncludeNames.Contains(student.Name, StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs b/SecRandom/Services/SecAgent/SecAgentHttpHostedService.cs deleted file mode 100644 index 3863c987..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, - 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 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 53a927b5bacb5319c49fa466c62b68ff803e742e Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Mon, 17 Aug 2026 19:33:17 +0800 Subject: [PATCH 2/3] fix: reset exhausted external draw rounds --- .../Draw/ExternalStudentDrawService.cs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/SecRandom/Services/Draw/ExternalStudentDrawService.cs b/SecRandom/Services/Draw/ExternalStudentDrawService.cs index c6368729..1200eb9f 100644 --- a/SecRandom/Services/Draw/ExternalStudentDrawService.cs +++ b/SecRandom/Services/Draw/ExternalStudentDrawService.cs @@ -43,17 +43,33 @@ public async Task DrawAsync( var gender = request.Gender?.Trim() ?? string.Empty; var listName = profileService.CurrentStudentList?.Name ?? string.Empty; var temporaryCounts = temporaryRecordService.GetStudentCounts(listName, gender, string.Empty); + var hasMatchingStudents = (profileService.CurrentStudentList?.Students ?? []) + .Any(student => student.IsCandidate && Matches(student, gender, request)); + var mayResetExhaustedRound = configHandler.Data.QuickDrawSettings.DrawMode != DrawMode.Repeat + && hasMatchingStudents; var result = await InvokeAuthorizedAsync( SecurityOperation.QuickDrawStart, async () => { - var draw = drawEngine.DrawStudent( - requestedCount, - student => Matches(student, gender, request) - && !HasReachedTemporaryLimit(student, temporaryCounts), - DrawSettingsType.QuickDraw, - linkageDrawCoordinator.GetCourseName()); + DrawResult DrawFromRemainingStudents() + => drawEngine.DrawStudent( + requestedCount, + student => Matches(student, gender, request) + && !HasReachedTemporaryLimit(student, temporaryCounts), + DrawSettingsType.QuickDraw, + linkageDrawCoordinator.GetCourseName()); + + var draw = DrawFromRemainingStudents(); + if (!draw.IsSuccess + && mayResetExhaustedRound + && draw.Status == DrawStatus.RepeatLimitExhausted) + { + temporaryRecordService.ResetStudentList(listName); + temporaryCounts = temporaryRecordService.GetStudentCounts(listName, gender, string.Empty); + draw = DrawFromRemainingStudents(); + } + if (!draw.IsSuccess || draw.Result.Count == 0) return draw; From 8ae4b810caaba7f0c9a59a886ad39097649e576a Mon Sep 17 00:00:00 2001 From: PANDA-JSR Date: Mon, 17 Aug 2026 19:38:44 +0800 Subject: [PATCH 3/3] fix: reset student history with draw rounds --- SecRandom/Services/Draw/ExternalStudentDrawService.cs | 1 + SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/SecRandom/Services/Draw/ExternalStudentDrawService.cs b/SecRandom/Services/Draw/ExternalStudentDrawService.cs index 1200eb9f..816c56e8 100644 --- a/SecRandom/Services/Draw/ExternalStudentDrawService.cs +++ b/SecRandom/Services/Draw/ExternalStudentDrawService.cs @@ -65,6 +65,7 @@ DrawResult DrawFromRemainingStudents() && mayResetExhaustedRound && draw.Status == DrawStatus.RepeatLimitExhausted) { + profileService.ClearCurrentStudentHistory(); temporaryRecordService.ResetStudentList(listName); temporaryCounts = temporaryRecordService.GetStudentCounts(listName, gender, string.Empty); draw = DrawFromRemainingStudents(); diff --git a/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs b/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs index d2a41fff..c50bd11c 100644 --- a/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs +++ b/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs @@ -435,6 +435,7 @@ private bool ResetForNewRoundIfExhausted() if (DrawCandidateFilter.FilterEligibleStudents(students, string.Empty, string.Empty, counts, threshold).Any()) return false; + _profileService.ClearCurrentStudentHistory(); _temporaryRecordService.ResetStudentList(SelectedStudentListName); return true; }