PUT /api/candidate/profile
行政区划、学校班级、特长类别校验 证件号码唯一性 自动创建资料审核工作流 资料和用户显示名称原子更新 POST /api/candidate/registrations 资料审核状态、报名时间和科目校验 重复报名防护 自动选择负载最低的审批管理员 报名、科目和审批流原子写入
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Application.Candidate;
|
||||
using Eis.Application.Public;
|
||||
using Eis.Infrastructure.Authentication;
|
||||
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
internal sealed partial class CandidateService(
|
||||
IAuthenticationStateStore authenticationState,
|
||||
AuthenticationRepository authenticationRepository,
|
||||
CandidateReadSnapshotLoader snapshotLoader,
|
||||
IPublicQueryService publicQueries,
|
||||
CandidateWriteRepository writeRepository,
|
||||
RegionCatalog regionCatalog) : 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user