using System.Globalization; using System.Text.Json.Nodes; using Eis.Application.Candidate; using Eis.Application.Public; using Eis.Infrastructure.Authentication; using Eis.Infrastructure.Security; namespace Eis.Infrastructure.Candidate; internal sealed partial class CandidateService( IAuthenticationStateStore authenticationState, AuthenticationRepository authenticationRepository, CandidateReadSnapshotLoader snapshotLoader, IPublicQueryService publicQueries, CandidateWriteRepository writeRepository, RegionCatalog regionCatalog, DocumentVerificationCodeService documentCodes, CandidateAdmissionRepository admissionRepository) : ICandidateService { public async Task GetDashboardAsync( string sessionToken, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); if (context.Error is not null) { return context.Error; } var user = context.User!; var profile = context.Profile!; var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken); var registrations = snapshot.Registrations.Select(item => RegistrationView(snapshot, item)).ToArray(); var registrationIds = snapshot.Registrations.Select(item => item.Id).ToHashSet(StringComparer.Ordinal); var results = snapshot.Results .Where(item => item.Published && registrationIds.Contains(item.RegistrationId)) .Select(ResultJson) .ToArray(); var home = await publicQueries.GetHomeAsync(cancellationToken); var notices = home["notices"]?.AsArray() .OfType() .OrderByDescending(item => ParseDate(item["publishAt"]?.GetValue())) .Take(5) .Select(item => item?.DeepClone()) .ToArray() ?? []; var profileInstance = FindInstance(snapshot, "profile_change", ProfileId(profile)); return Success(new JsonObject { ["ok"] = true, ["profile"] = profile, ["profileWorkflow"] = WorkflowView(snapshot, profileInstance), ["registrations"] = new JsonArray(registrations), ["results"] = new JsonArray(results), ["notices"] = new JsonArray(notices) }); } public async Task GetNoticesAsync( string sessionToken, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); if (context.Error is not null) { return context.Error; } var home = await publicQueries.GetHomeAsync(cancellationToken); return Success(new JsonObject { ["ok"] = true, ["notices"] = home["notices"]?.DeepClone() ?? new JsonArray() }); } public async Task GetProfileAsync( string sessionToken, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: true, cancellationToken); if (context.Error is not null) { return context.Error; } var snapshot = await snapshotLoader.LoadAsync(context.User!.Id, cancellationToken); var profile = context.Profile!; var instance = FindInstance(snapshot, "profile_change", ProfileId(profile)); var home = await publicQueries.GetHomeAsync(cancellationToken); return Success(new JsonObject { ["ok"] = true, ["profile"] = profile, ["workflow"] = WorkflowView(snapshot, instance), ["schools"] = home["schools"]?.DeepClone() ?? new JsonArray(), ["classes"] = home["classes"]?.DeepClone() ?? new JsonArray() }); } public async Task GetExamsAsync( string sessionToken, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); if (context.Error is not null) { return context.Error; } var snapshot = await snapshotLoader.LoadAsync(context.User!.Id, cancellationToken); var registrationsByExam = snapshot.Registrations .GroupBy(item => item.ExamId) .ToDictionary(group => group.Key, group => group.First(), StringComparer.Ordinal); var home = await publicQueries.GetHomeAsync(cancellationToken); var exams = home["exams"]?.AsArray().OfType().Select(item => { var exam = item.DeepClone().AsObject(); exam.Remove("registrationCount"); var examId = exam["id"]?.GetValue() ?? string.Empty; exam["registration"] = registrationsByExam.TryGetValue(examId, out var registration) ? RegistrationJson(registration) : null; return exam; }).ToArray() ?? []; return Success(new JsonObject { ["ok"] = true, ["profileStatus"] = context.Profile!["status"]?.GetValue() ?? string.Empty, ["exams"] = new JsonArray(exams) }); } public async Task GetRegistrationsAsync( string sessionToken, CancellationToken cancellationToken) { var context = await ResolveAsync(sessionToken, profileRoute: false, cancellationToken); if (context.Error is not null) { return context.Error; } var snapshot = await snapshotLoader.LoadAsync(context.User!.Id, cancellationToken); return Success(new JsonObject { ["ok"] = true, ["registrations"] = new JsonArray(snapshot.Registrations.Select(item => RegistrationView(snapshot, item)).ToArray()) }); } private async Task ResolveAsync( string sessionToken, bool profileRoute, CancellationToken cancellationToken) { if (sessionToken.Length == 0) { return ResolvedCandidate.Failed(Error(401, "请先登录")); } var userId = await authenticationState.GetSessionUserIdAsync(sessionToken); if (userId is null) { return ResolvedCandidate.Failed(Error(401, "请先登录")); } var user = await authenticationRepository.FindUserByIdAsync(userId, cancellationToken); if (user is not { Active: true, ArchivedAt: null }) { return ResolvedCandidate.Failed(Error(401, "请先登录")); } if (user.Role != "candidate") { return ResolvedCandidate.Failed(Error(403, "当前账号无权执行此操作")); } var profile = await authenticationRepository.GetCandidateProfileAsync(user.Id, cancellationToken); if (profile is null) { return ResolvedCandidate.Failed(Error(404, "考生资料不存在")); } if (user.MustChangePassword) { return ResolvedCandidate.Failed(Error(428, "首次登录必须先修改初始密码")); } if (!profileRoute && profile["profileCompleted"]?.GetValue() != true) { return ResolvedCandidate.Failed(Error(428, "请先补全个人信息并提交审核")); } return new ResolvedCandidate(user, profile, null); } private static CandidateWorkflowInstance? FindInstance( CandidateReadSnapshot snapshot, string businessType, string businessId) { var matches = snapshot.WorkflowInstances .Where(item => item.BusinessType == businessType && item.BusinessId == businessId) .ToArray(); return matches.FirstOrDefault(item => item.Status == "pending") ?? matches.FirstOrDefault(); } private static JsonObject RegistrationView(CandidateReadSnapshot snapshot, CandidateRegistration registration) { var json = RegistrationJson(registration); var exam = snapshot.Exams.FirstOrDefault(item => item.Id == registration.ExamId); var subjects = exam?.Subjects.Where(item => registration.SubjectIds.Contains(item.Id, StringComparer.Ordinal)).ToArray() ?? []; var instance = FindInstance(snapshot, "registration_review", registration.Id); json["exam"] = exam is null ? null : ExamJson(exam); json["subjects"] = new JsonArray(subjects.Select(SubjectJson).ToArray()); json["amountDue"] = Math.Round(subjects.Sum(item => item.Fee), 2, MidpointRounding.AwayFromZero); json["paidByName"] = registration.PaidBy is not null && snapshot.Users.TryGetValue(registration.PaidBy, out var payer) ? payer.DisplayName : string.Empty; json["workflow"] = WorkflowView(snapshot, instance); return json; } private static JsonObject RegistrationJson(CandidateRegistration item) => new() { ["id"] = item.Id, ["userId"] = item.UserId, ["examId"] = item.ExamId, ["subjectIds"] = new JsonArray(item.SubjectIds.Select(value => JsonValue.Create(value)).ToArray()), ["status"] = item.Status, ["paymentStatus"] = item.PaymentStatus, ["paidAt"] = JsonValue.Create(item.PaidAt), ["paidBy"] = JsonValue.Create(item.PaidBy), ["createdAt"] = item.CreatedAt, ["reviewedAt"] = JsonValue.Create(item.ReviewedAt), ["reviewNote"] = item.ReviewNote, ["registrationNumber"] = item.RegistrationNumber, ["numberRuleId"] = JsonValue.Create(item.NumberRuleId), ["featureScore"] = item.FeatureScore, ["admitCard"] = item.AdmitCard is null ? null : AdmitCardJson(item.AdmitCard) }; private static JsonObject AdmitCardJson(CandidateAdmitCard item) => new() { ["planId"] = item.PlanId, ["number"] = item.Number, ["centerId"] = JsonValue.Create(item.CenterId), ["testCenter"] = item.TestCenter, ["centerCode"] = item.CenterCode, ["centerAddress"] = item.CenterAddress, ["room"] = item.Room, ["seat"] = item.Seat, ["assignments"] = new JsonArray(item.Assignments.Select(AssignmentJson).ToArray()), ["generatedAt"] = item.GeneratedAt }; private static JsonObject AssignmentJson(CandidateAdmitAssignment item) => new() { ["subjectId"] = item.SubjectId, ["roomId"] = JsonValue.Create(item.RoomId), ["roomName"] = item.RoomName, ["room"] = item.RoomName, ["roomCode"] = item.RoomCode, ["examRoomCode"] = item.ExamRoomCode, ["building"] = item.Building, ["floor"] = item.Floor, ["seat"] = item.Seat, ["subjectSignature"] = item.SubjectSignature }; private static JsonObject ExamJson(CandidateExam item) => new() { ["id"] = item.Id, ["code"] = item.Code, ["name"] = item.Name, ["description"] = item.Description, ["registrationStart"] = item.RegistrationStart, ["registrationEnd"] = item.RegistrationEnd, ["examStart"] = item.ExamStart, ["examEnd"] = item.ExamEnd, ["admitDownloadStart"] = item.AdmitDownloadStart, ["admitDownloadEnd"] = item.AdmitDownloadEnd, ["location"] = item.Location, ["passPolicy"] = item.PassPolicy, ["passValue"] = item.PassValue, ["status"] = item.Status, ["archivedAt"] = JsonValue.Create(item.ArchivedAt), ["archivedBy"] = JsonValue.Create(item.ArchivedBy), ["createdAt"] = item.CreatedAt, ["subjects"] = new JsonArray(item.Subjects.Select(SubjectJson).ToArray()) }; private static JsonObject SubjectJson(CandidateSubject item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["date"] = item.Date, ["start"] = item.Start, ["end"] = item.End, ["fee"] = item.Fee, ["fullScore"] = item.FullScore, ["passRule"] = item.PassRule, ["passValue"] = item.PassValue, ["passScore"] = JsonValue.Create(item.PassScore), ["order"] = item.Order }; private static JsonObject ResultJson(CandidateResult item) => new() { ["id"] = item.Id, ["registrationId"] = item.RegistrationId, ["subjectId"] = item.SubjectId, ["score"] = item.Score, ["grade"] = item.Grade, ["published"] = item.Published, ["updatedAt"] = JsonValue.Create(item.UpdatedAt), ["publishedAt"] = JsonValue.Create(item.PublishedAt) }; private static JsonObject? WorkflowView( CandidateReadSnapshot snapshot, CandidateWorkflowInstance? instance) { if (instance is null) { return null; } var workflow = snapshot.Workflows.FirstOrDefault(item => item.Id == instance.WorkflowId); var steps = workflow?.Steps ?? []; snapshot.Users.TryGetValue(instance.AssigneeId ?? string.Empty, out var assignee); var actions = snapshot.WorkflowActions.Where(item => item.InstanceId == instance.Id).Select(item => new JsonObject { ["id"] = item.Id, ["instanceId"] = item.InstanceId, ["actorId"] = JsonValue.Create(item.ActorId), ["action"] = item.Action, ["note"] = item.Note, ["fromAssigneeId"] = JsonValue.Create(item.FromAssigneeId), ["toAssigneeId"] = JsonValue.Create(item.ToAssigneeId), ["createdAt"] = item.CreatedAt, ["actorName"] = UserName(snapshot, item.ActorId, "系统"), ["fromAssigneeName"] = UserName(snapshot, item.FromAssigneeId, string.Empty), ["toAssigneeName"] = UserName(snapshot, item.ToAssigneeId, string.Empty) }).ToArray(); return new JsonObject { ["id"] = instance.Id, ["workflowId"] = instance.WorkflowId, ["businessType"] = instance.BusinessType, ["businessId"] = instance.BusinessId, ["status"] = instance.Status, ["currentStep"] = instance.CurrentStep, ["assigneeId"] = JsonValue.Create(instance.AssigneeId), ["createdAt"] = instance.CreatedAt, ["completedAt"] = JsonValue.Create(instance.CompletedAt), ["workflowName"] = workflow?.Name ?? "未命名流程", ["steps"] = new JsonArray(steps.Select(WorkflowStepJson).ToArray()), ["currentStepDetail"] = steps.FirstOrDefault(item => item.Position == instance.CurrentStep) is { } current ? WorkflowStepJson(current) : null, ["assignee"] = assignee is null ? null : SafeUser(assignee), ["actions"] = new JsonArray(actions) }; } private static JsonObject WorkflowStepJson(CandidateWorkflowStep item) => new() { ["id"] = item.Id, ["name"] = item.Name, ["adminLevel"] = item.AdminLevel, ["position"] = item.Position }; private static JsonObject SafeUser(CandidateUserSummary user) => new() { ["id"] = user.Id, ["username"] = user.Username, ["role"] = user.Role, ["adminLevel"] = user.Role == "admin" ? user.AdminLevel ?? "super" : null, ["schoolId"] = JsonValue.Create(user.SchoolId), ["classId"] = JsonValue.Create(user.ClassId), ["displayName"] = user.DisplayName, ["candidateNumber"] = JsonValue.Create(user.CandidateNumber), ["mustChangePassword"] = user.MustChangePassword, ["totpEnabled"] = user.TotpEnabled, ["archived"] = user.ArchivedAt is not null }; private static string UserName(CandidateReadSnapshot snapshot, string? id, string fallback) => id is not null && snapshot.Users.TryGetValue(id, out var user) ? user.DisplayName : fallback; private static string ProfileId(JsonObject profile) => profile["id"]?.GetValue() ?? string.Empty; private static DateTimeOffset ParseDate(string? value) => DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var parsed) ? parsed : DateTimeOffset.MinValue; private static CandidateEndpointResult Success(JsonObject body) => new(200, body); private static CandidateEndpointResult Error(int statusCode, string message) => new( statusCode, new JsonObject { ["ok"] = false, ["message"] = message }); private sealed record ResolvedCandidate( AuthenticationUser? User, JsonObject? Profile, CandidateEndpointResult? Error) { public static ResolvedCandidate Failed(CandidateEndpointResult error) => new(null, null, error); } }