志愿提交、资格校验、补录限制、次数上限与自动锁定:[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)
416 lines
16 KiB
C#
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);
|
|
}
|
|
}
|