Files
EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.cs
T
biss 4b0dae6d71 原生志愿、录取、计划余量、指标资格和通知查询:[CandidateService.Admissions.cs (line 29)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs:29)
志愿提交、资格校验、补录限制、次数上限与自动锁定:[CandidateService.Admissions.cs (line 152)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateService.Admissions.cs:152)
通用招生记录读取及事务写入:[CandidateAdmissionRepository.cs (line 25)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Candidate/CandidateAdmissionRepository.cs:25)
原生路由已接入:[NativeCandidateEndpoints.cs (line 70)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Web/Candidate/NativeCandidateEndpoints.cs:70)
迁移状态现在会把 Candidate 标记为原生:[MigrationFeatureCatalog.cs (line 12)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Migration/MigrationFeatureCatalog.cs:12)
2026-07-22 20:22:46 +08:00

416 lines
16 KiB
C#

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<CandidateEndpointResult> 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<JsonObject>()
.OrderByDescending(item => ParseDate(item["publishAt"]?.GetValue<string>()))
.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<CandidateEndpointResult> 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<CandidateEndpointResult> 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<CandidateEndpointResult> 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<JsonObject>().Select(item =>
{
var exam = item.DeepClone().AsObject();
exam.Remove("registrationCount");
var examId = exam["id"]?.GetValue<string>() ?? 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>() ?? string.Empty,
["exams"] = new JsonArray(exams)
});
}
public async Task<CandidateEndpointResult> 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<ResolvedCandidate> 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<bool>() != 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>() ?? 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);
}
}