PUT /api/candidate/profile
行政区划、学校班级、特长类别校验 证件号码唯一性 自动创建资料审核工作流 资料和用户显示名称原子更新 POST /api/candidate/registrations 资料审核状态、报名时间和科目校验 重复报名防护 自动选择负载最低的审批管理员 报名、科目和审批流原子写入
This commit is contained in:
+11
-1
@@ -4,7 +4,7 @@ namespace Eis.Application.Candidate;
|
||||
|
||||
public sealed record CandidateEndpointResult(int StatusCode, JsonObject Body);
|
||||
|
||||
public interface ICandidateQueryService
|
||||
public interface ICandidateService
|
||||
{
|
||||
Task<CandidateEndpointResult> GetDashboardAsync(string sessionToken, CancellationToken cancellationToken);
|
||||
|
||||
@@ -15,4 +15,14 @@ public interface ICandidateQueryService
|
||||
Task<CandidateEndpointResult> GetExamsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<CandidateEndpointResult> GetRegistrationsAsync(string sessionToken, CancellationToken cancellationToken);
|
||||
|
||||
Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<CandidateEndpointResult> CreateRegistrationAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
public sealed record CandidateMigrationOptions(bool NativeReadEnabled)
|
||||
public sealed record CandidateMigrationOptions(bool NativeEnabled)
|
||||
{
|
||||
public static CandidateMigrationOptions FromEnvironment(
|
||||
bool configuredNativeReadEnabled,
|
||||
bool configuredNativeEnabled,
|
||||
bool authenticationNativeEnabled,
|
||||
bool sharesLegacySessions)
|
||||
{
|
||||
var enabled = ParseBoolean(
|
||||
Environment.GetEnvironmentVariable("CANDIDATE_NATIVE_ENABLED"),
|
||||
configuredNativeReadEnabled);
|
||||
configuredNativeEnabled);
|
||||
if (enabled && !authenticationNativeEnabled)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
||||
@@ -141,9 +141,11 @@ internal sealed record CandidateUserSummary(
|
||||
string? ClassId,
|
||||
string DisplayName,
|
||||
string? CandidateNumber,
|
||||
bool Active,
|
||||
bool MustChangePassword,
|
||||
bool TotpEnabled,
|
||||
string? ArchivedAt);
|
||||
string? ArchivedAt,
|
||||
string CreatedAt);
|
||||
|
||||
internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory connectionFactory)
|
||||
{
|
||||
@@ -262,7 +264,7 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
||||
ReadWorkflowAction,
|
||||
cancellationToken);
|
||||
var users = await QueryAsync(connection,
|
||||
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, must_change_password, totp_enabled, archived_at FROM users ORDER BY created_at, id",
|
||||
"SELECT id, username, role, admin_level, school_id, class_id, display_name, candidate_number, active, must_change_password, totp_enabled, archived_at, created_at FROM users ORDER BY created_at, id",
|
||||
ReadUserSummary,
|
||||
cancellationToken);
|
||||
|
||||
@@ -435,18 +437,24 @@ internal sealed class CandidateReadSnapshotLoader(IRelationalConnectionFactory c
|
||||
ReadOptionalString(reader, "to_assignee_id"),
|
||||
ReadString(reader, "created_at"));
|
||||
|
||||
private static CandidateUserSummary ReadUserSummary(DbDataReader reader) => new(
|
||||
ReadString(reader, "id"),
|
||||
ReadString(reader, "username"),
|
||||
ReadString(reader, "role"),
|
||||
ReadOptionalString(reader, "admin_level"),
|
||||
ReadOptionalString(reader, "school_id"),
|
||||
ReadOptionalString(reader, "class_id"),
|
||||
ReadString(reader, "display_name"),
|
||||
ReadOptionalString(reader, "candidate_number"),
|
||||
ReadBoolean(reader, "must_change_password"),
|
||||
ReadBoolean(reader, "totp_enabled"),
|
||||
ReadOptionalString(reader, "archived_at"));
|
||||
private static CandidateUserSummary ReadUserSummary(DbDataReader reader)
|
||||
{
|
||||
var role = ReadString(reader, "role");
|
||||
return new CandidateUserSummary(
|
||||
ReadString(reader, "id"),
|
||||
ReadString(reader, "username"),
|
||||
role,
|
||||
ReadOptionalString(reader, "admin_level") ?? (role == "admin" ? "super" : null),
|
||||
ReadOptionalString(reader, "school_id"),
|
||||
ReadOptionalString(reader, "class_id"),
|
||||
ReadString(reader, "display_name"),
|
||||
ReadOptionalString(reader, "candidate_number"),
|
||||
ReadBoolean(reader, "active"),
|
||||
ReadBoolean(reader, "must_change_password"),
|
||||
ReadBoolean(reader, "totp_enabled"),
|
||||
ReadOptionalString(reader, "archived_at"),
|
||||
ReadString(reader, "created_at"));
|
||||
}
|
||||
|
||||
private static string ReadString(DbDataReader reader, string name) =>
|
||||
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Application.Candidate;
|
||||
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
internal sealed partial class CandidateService
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string> AdminLevelNames =
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["super"] = "超级管理员",
|
||||
["school"] = "校级管理员",
|
||||
["class"] = "班级管理员"
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string[]> SpecialtyTypes =
|
||||
new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["sports"] =
|
||||
[
|
||||
"track_field", "basketball", "football", "volleyball", "table_tennis", "badminton",
|
||||
"swimming", "martial_arts", "aerobics_cheer"
|
||||
],
|
||||
["arts"] =
|
||||
[
|
||||
"vocal_music", "instrumental_music", "dance", "fine_arts", "calligraphy", "drama_broadcasting"
|
||||
]
|
||||
};
|
||||
|
||||
public async Task<CandidateEndpointResult> UpdateProfileAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var context = await ResolveAsync(sessionToken, profileRoute: true, cancellationToken);
|
||||
if (context.Error is not null)
|
||||
{
|
||||
return context.Error;
|
||||
}
|
||||
|
||||
var user = context.User!;
|
||||
var profile = context.Profile!;
|
||||
foreach (var field in new[]
|
||||
{
|
||||
"name", "gender", "idNumber", "phone", "email", "address", "emergencyContact",
|
||||
"emergencyPhone", "nativePlace", "birthDate", "ethnicity", "postalCode", "guardianName",
|
||||
"guardianPhone", "specialtyCertificate", "policyEligibility"
|
||||
})
|
||||
{
|
||||
profile[field] = Clean(body, field, field == "address" ? 160 : 80);
|
||||
}
|
||||
|
||||
var specialtyCategory = Clean(body, "specialtyCategory", 30);
|
||||
var specialtyType = Clean(body, "specialtyType", 40);
|
||||
if (!ValidSpecialty(specialtyCategory, specialtyType))
|
||||
{
|
||||
return Error(400, "请选择对应的特长生大类和小类");
|
||||
}
|
||||
|
||||
profile["specialtyCategory"] = specialtyCategory;
|
||||
profile["specialtyType"] = specialtyType;
|
||||
profile["specialtyTypes"] = specialtyType.Length == 0
|
||||
? new JsonArray()
|
||||
: new JsonArray(JsonValue.Create(specialtyType));
|
||||
|
||||
var region = regionCatalog.Resolve(
|
||||
Text(body, "provinceCode"),
|
||||
Text(body, "cityCode"),
|
||||
Text(body, "districtCode"));
|
||||
if (region is null)
|
||||
{
|
||||
return Error(400, "请选择有效的省、市和区县");
|
||||
}
|
||||
|
||||
profile["provinceCode"] = region.ProvinceCode;
|
||||
profile["provinceName"] = region.ProvinceName;
|
||||
profile["cityCode"] = region.CityCode;
|
||||
profile["cityName"] = region.CityName;
|
||||
profile["districtCode"] = region.DistrictCode;
|
||||
profile["districtName"] = region.DistrictName;
|
||||
|
||||
var school = await writeRepository.FindSchoolClassAsync(
|
||||
Clean(body, "schoolId", 64),
|
||||
Clean(body, "classId", 64),
|
||||
cancellationToken);
|
||||
if (school is null)
|
||||
{
|
||||
return Error(400, "请选择有效的学校和班级");
|
||||
}
|
||||
|
||||
profile["schoolId"] = school.SchoolId;
|
||||
profile["classId"] = school.ClassId;
|
||||
profile["school"] = school.SchoolName;
|
||||
profile["grade"] = school.ClassName;
|
||||
var name = Text(profile, "name");
|
||||
var gender = Text(profile, "gender");
|
||||
var idNumber = Text(profile, "idNumber");
|
||||
if (name.Length == 0 || gender is not ("男" or "女") || idNumber.Length == 0 ||
|
||||
idNumber.StartsWith("PENDING-", StringComparison.Ordinal) ||
|
||||
Text(profile, "nativePlace").Length == 0 || Text(profile, "address").Length == 0 ||
|
||||
Text(profile, "phone").Length == 0 || Text(profile, "email").Length == 0 ||
|
||||
Text(profile, "school").Length == 0 || Text(profile, "classId").Length == 0)
|
||||
{
|
||||
return Error(400, "请完整填写姓名、性别、证件号码、籍贯、省市区县、家庭住址、手机号、邮箱、学校和班级");
|
||||
}
|
||||
|
||||
if (await writeRepository.IdNumberExistsAsync(idNumber, ProfileId(profile), cancellationToken))
|
||||
{
|
||||
return Error(409, "证件号码已被其他考生使用");
|
||||
}
|
||||
|
||||
profile["status"] = "pending";
|
||||
profile["profileCompleted"] = true;
|
||||
profile["reviewNote"] = string.Empty;
|
||||
profile["updatedAt"] = NowIso();
|
||||
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
|
||||
WorkflowSubmission? submission = null;
|
||||
if (FindInstance(snapshot, "profile_change", ProfileId(profile)) is not { Status: "pending" })
|
||||
{
|
||||
var workflowResult = CreateWorkflowSubmission(
|
||||
snapshot,
|
||||
"profile_change",
|
||||
ProfileId(profile),
|
||||
profile,
|
||||
user.Id);
|
||||
if (workflowResult.Error is not null)
|
||||
{
|
||||
return workflowResult.Error;
|
||||
}
|
||||
submission = workflowResult.Submission;
|
||||
}
|
||||
|
||||
await writeRepository.UpdateProfileAsync(
|
||||
profile,
|
||||
name,
|
||||
submission?.Instance,
|
||||
submission?.Action,
|
||||
cancellationToken);
|
||||
return Success(new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["profile"] = profile,
|
||||
["message"] = "资料已提交,等待管理员复核"
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<CandidateEndpointResult> CreateRegistrationAsync(
|
||||
string sessionToken,
|
||||
JsonObject body,
|
||||
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!;
|
||||
if (Text(profile, "status") != "approved")
|
||||
{
|
||||
return Error(403, "个人资料审核通过后才能报名考试");
|
||||
}
|
||||
|
||||
var snapshot = await snapshotLoader.LoadAsync(user.Id, cancellationToken);
|
||||
var examId = Text(body, "examId");
|
||||
var exam = snapshot.Exams.FirstOrDefault(item =>
|
||||
item.Id == examId && item.Status == "published" && item.ArchivedAt is null);
|
||||
if (exam is null)
|
||||
{
|
||||
return Error(404, "考试不存在或尚未发布");
|
||||
}
|
||||
|
||||
var registrationState = RegistrationState(exam);
|
||||
if (registrationState != "open")
|
||||
{
|
||||
return Error(400, registrationState == "upcoming" ? "报名尚未开始" : "报名已经截止");
|
||||
}
|
||||
|
||||
if (snapshot.Registrations.Any(item => item.ExamId == exam.Id) ||
|
||||
await writeRepository.RegistrationExistsAsync(user.Id, exam.Id, cancellationToken))
|
||||
{
|
||||
return Error(409, "你已经报名该考试");
|
||||
}
|
||||
|
||||
var subjectIds = body["subjectIds"] is JsonArray array
|
||||
? array.Select(item => item?.ToString() ?? string.Empty)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray()
|
||||
: [];
|
||||
if (subjectIds.Length == 0 || subjectIds.Any(id => exam.Subjects.All(subject => subject.Id != id)))
|
||||
{
|
||||
return Error(400, "请选择有效的报考科目");
|
||||
}
|
||||
|
||||
var now = NowIso();
|
||||
var registration = new CandidateRegistration(
|
||||
Uid("reg"),
|
||||
user.Id,
|
||||
exam.Id,
|
||||
subjectIds,
|
||||
"pending",
|
||||
"unpaid",
|
||||
null,
|
||||
null,
|
||||
now,
|
||||
null,
|
||||
string.Empty,
|
||||
user.CandidateNumber ?? string.Empty,
|
||||
await writeRepository.GetActiveNumberRuleIdAsync(cancellationToken),
|
||||
0,
|
||||
null);
|
||||
var workflowResult = CreateWorkflowSubmission(
|
||||
snapshot,
|
||||
"registration_review",
|
||||
registration.Id,
|
||||
profile,
|
||||
user.Id);
|
||||
if (workflowResult.Error is not null)
|
||||
{
|
||||
return workflowResult.Error;
|
||||
}
|
||||
|
||||
var submission = workflowResult.Submission!;
|
||||
await writeRepository.CreateRegistrationAsync(
|
||||
registration,
|
||||
submission.Instance,
|
||||
submission.Action,
|
||||
cancellationToken);
|
||||
return new CandidateEndpointResult(
|
||||
201,
|
||||
new JsonObject
|
||||
{
|
||||
["ok"] = true,
|
||||
["registration"] = RegistrationView(snapshot, registration),
|
||||
["message"] = "考试报名已提交"
|
||||
});
|
||||
}
|
||||
|
||||
private static WorkflowSubmissionResult CreateWorkflowSubmission(
|
||||
CandidateReadSnapshot snapshot,
|
||||
string businessType,
|
||||
string businessId,
|
||||
JsonObject profile,
|
||||
string actorId)
|
||||
{
|
||||
var workflow = snapshot.Workflows.FirstOrDefault(item => item.BusinessType == businessType && item.Active);
|
||||
if (workflow is null || workflow.Steps.Count == 0)
|
||||
{
|
||||
return WorkflowSubmissionResult.Failed(Error(409, "该业务尚未配置审批流程"));
|
||||
}
|
||||
|
||||
var firstStep = workflow.Steps[0];
|
||||
var schoolId = Text(profile, "schoolId");
|
||||
var classId = Text(profile, "classId");
|
||||
var pendingByAdmin = snapshot.WorkflowInstances
|
||||
.Where(item => item.Status == "pending" && item.AssigneeId is not null)
|
||||
.GroupBy(item => item.AssigneeId!, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||
var assignedByAdmin = snapshot.WorkflowActions
|
||||
.Where(item => item.ToAssigneeId is not null)
|
||||
.GroupBy(item => item.ToAssigneeId!, StringComparer.Ordinal)
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal);
|
||||
var assignee = snapshot.Users.Values
|
||||
.Where(item => item.Role == "admin" && item.Active && (item.AdminLevel ?? "super") == firstStep.AdminLevel)
|
||||
.Where(item => firstStep.AdminLevel switch
|
||||
{
|
||||
"super" => true,
|
||||
"school" => schoolId.Length > 0 && item.SchoolId == schoolId,
|
||||
"class" => schoolId.Length > 0 && classId.Length > 0 && item.SchoolId == schoolId && item.ClassId == classId,
|
||||
_ => false
|
||||
})
|
||||
.OrderBy(item => pendingByAdmin.GetValueOrDefault(item.Id))
|
||||
.ThenBy(item => assignedByAdmin.GetValueOrDefault(item.Id))
|
||||
.ThenBy(item => item.CreatedAt, StringComparer.Ordinal)
|
||||
.ThenBy(item => item.Id, StringComparer.Ordinal)
|
||||
.FirstOrDefault();
|
||||
if (assignee is null)
|
||||
{
|
||||
var levelName = AdminLevelNames.GetValueOrDefault(firstStep.AdminLevel) ?? firstStep.AdminLevel;
|
||||
return WorkflowSubmissionResult.Failed(Error(409, $"没有可承接“{firstStep.Name}”的{levelName}"));
|
||||
}
|
||||
|
||||
var instance = new CandidateWorkflowInstance(
|
||||
Uid("flow"),
|
||||
workflow.Id,
|
||||
businessType,
|
||||
businessId,
|
||||
"pending",
|
||||
1,
|
||||
assignee.Id,
|
||||
NowIso(),
|
||||
null);
|
||||
var action = new CandidateWorkflowAction(
|
||||
Uid("flow_action"),
|
||||
instance.Id,
|
||||
actorId,
|
||||
"submit",
|
||||
"提交审批",
|
||||
null,
|
||||
assignee.Id,
|
||||
NowIso());
|
||||
return new WorkflowSubmissionResult(new WorkflowSubmission(instance, action), null);
|
||||
}
|
||||
|
||||
private static bool ValidSpecialty(string category, string type)
|
||||
{
|
||||
if (category.Length == 0 && type.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return SpecialtyTypes.TryGetValue(category, out var types) && types.Contains(type, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private static string RegistrationState(CandidateExam exam)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if (DateTimeOffset.TryParse(exam.RegistrationStart, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var start) && now < start)
|
||||
{
|
||||
return "upcoming";
|
||||
}
|
||||
|
||||
if (DateTimeOffset.TryParse(exam.RegistrationEnd, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var end) && now > end)
|
||||
{
|
||||
return "closed";
|
||||
}
|
||||
|
||||
return "open";
|
||||
}
|
||||
|
||||
private static string Clean(JsonObject value, string property, int maximum)
|
||||
{
|
||||
var normalized = Text(value, property).Trim();
|
||||
return normalized[..Math.Min(normalized.Length, maximum)];
|
||||
}
|
||||
|
||||
private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty;
|
||||
|
||||
private static string NowIso() => DateTimeOffset.UtcNow.ToString(
|
||||
"yyyy-MM-dd'T'HH:mm:ss.fff'Z'",
|
||||
CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Uid(string prefix) =>
|
||||
$"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(4))}";
|
||||
|
||||
private static string ToBase36(long value)
|
||||
{
|
||||
const string alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
|
||||
Span<char> buffer = stackalloc char[16];
|
||||
var position = buffer.Length;
|
||||
do
|
||||
{
|
||||
buffer[--position] = alphabet[(int)(value % 36)];
|
||||
value /= 36;
|
||||
}
|
||||
while (value > 0);
|
||||
return new string(buffer[position..]);
|
||||
}
|
||||
|
||||
private sealed record WorkflowSubmission(
|
||||
CandidateWorkflowInstance Instance,
|
||||
CandidateWorkflowAction Action);
|
||||
|
||||
private sealed record WorkflowSubmissionResult(
|
||||
WorkflowSubmission? Submission,
|
||||
CandidateEndpointResult? Error)
|
||||
{
|
||||
public static WorkflowSubmissionResult Failed(CandidateEndpointResult error) => new(null, error);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -6,11 +6,13 @@ using Eis.Infrastructure.Authentication;
|
||||
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
internal sealed class CandidateQueryService(
|
||||
internal sealed partial class CandidateService(
|
||||
IAuthenticationStateStore authenticationState,
|
||||
AuthenticationRepository authenticationRepository,
|
||||
CandidateReadSnapshotLoader snapshotLoader,
|
||||
IPublicQueryService publicQueries) : ICandidateQueryService
|
||||
IPublicQueryService publicQueries,
|
||||
CandidateWriteRepository writeRepository,
|
||||
RegionCatalog regionCatalog) : ICandidateService
|
||||
{
|
||||
public async Task<CandidateEndpointResult> GetDashboardAsync(
|
||||
string sessionToken,
|
||||
@@ -0,0 +1,312 @@
|
||||
using System.Data.Common;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Nodes;
|
||||
using Eis.Infrastructure.Data;
|
||||
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
internal sealed record SchoolClassSelection(
|
||||
string SchoolId,
|
||||
string SchoolName,
|
||||
string ClassId,
|
||||
string ClassName);
|
||||
|
||||
internal sealed class CandidateWriteRepository(IRelationalConnectionFactory connectionFactory)
|
||||
{
|
||||
public async Task<SchoolClassSelection?> FindSchoolClassAsync(
|
||||
string schoolId,
|
||||
string classId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT school.id AS school_id, school.name AS school_name,
|
||||
class.id AS class_id, class.name AS class_name
|
||||
FROM schools school
|
||||
JOIN school_classes class ON class.school_id = school.id
|
||||
WHERE school.id = @schoolId AND class.id = @classId
|
||||
AND school.active = 1 AND school.is_source_school = 1 AND class.active = 1
|
||||
LIMIT 1
|
||||
""";
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var command = CreateCommand(connection, sql,
|
||||
[
|
||||
new("@schoolId", schoolId),
|
||||
new("@classId", classId)
|
||||
]);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
return await reader.ReadAsync(cancellationToken)
|
||||
? new SchoolClassSelection(
|
||||
ReadString(reader, "school_id"),
|
||||
ReadString(reader, "school_name"),
|
||||
ReadString(reader, "class_id"),
|
||||
ReadString(reader, "class_name"))
|
||||
: null;
|
||||
}
|
||||
|
||||
public async Task<bool> IdNumberExistsAsync(
|
||||
string idNumber,
|
||||
string excludedProfileId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var command = CreateCommand(connection,
|
||||
"SELECT COUNT(*) FROM candidate_profiles WHERE id <> @profileId AND id_number = @idNumber",
|
||||
[new("@profileId", excludedProfileId), new("@idNumber", idNumber)]);
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> RegistrationExistsAsync(
|
||||
string userId,
|
||||
string examId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var command = CreateCommand(connection,
|
||||
"SELECT COUNT(*) FROM registrations WHERE user_id = @userId AND exam_id = @examId",
|
||||
[new("@userId", userId), new("@examId", examId)]);
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync(cancellationToken), CultureInfo.InvariantCulture) > 0;
|
||||
}
|
||||
|
||||
public async Task<string?> GetActiveNumberRuleIdAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var command = CreateCommand(connection,
|
||||
"SELECT id FROM number_rules WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1",
|
||||
[]);
|
||||
var value = await command.ExecuteScalarAsync(cancellationToken);
|
||||
return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
public async Task UpdateProfileAsync(
|
||||
JsonObject profile,
|
||||
string displayName,
|
||||
CandidateWorkflowInstance? instance,
|
||||
CandidateWorkflowAction? action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE candidate_profiles SET
|
||||
name = @name, gender = @gender, id_number = @idNumber, phone = @phone, email = @email,
|
||||
school = @school, grade = @grade, province_code = @provinceCode, province_name = @provinceName,
|
||||
city_code = @cityCode, city_name = @cityName, district_code = @districtCode,
|
||||
district_name = @districtName, address = @address, school_id = @schoolId, class_id = @classId,
|
||||
emergency_contact = @emergencyContact, emergency_phone = @emergencyPhone, status = @status,
|
||||
review_note = @reviewNote, native_place = @nativePlace, birth_date = @birthDate,
|
||||
ethnicity = @ethnicity, postal_code = @postalCode, guardian_name = @guardianName,
|
||||
guardian_phone = @guardianPhone, specialty_category = @specialtyCategory,
|
||||
specialty_type = @specialtyType, specialty_types = @specialtyTypes,
|
||||
specialty_certificate = @specialtyCertificate, policy_eligibility = @policyEligibility,
|
||||
profile_completed = @profileCompleted, reviewed_at = @reviewedAt, reviewer_id = @reviewerId,
|
||||
updated_at = @updatedAt
|
||||
WHERE id = @id
|
||||
""";
|
||||
var parameters = new QueryParameter[]
|
||||
{
|
||||
new("@name", Text(profile, "name")),
|
||||
new("@gender", Optional(Text(profile, "gender"))),
|
||||
new("@idNumber", Text(profile, "idNumber")),
|
||||
new("@phone", Text(profile, "phone")),
|
||||
new("@email", Optional(Text(profile, "email"))),
|
||||
new("@school", Optional(Text(profile, "school"))),
|
||||
new("@grade", Optional(Text(profile, "grade"))),
|
||||
new("@provinceCode", Optional(Text(profile, "provinceCode"))),
|
||||
new("@provinceName", Optional(Text(profile, "provinceName"))),
|
||||
new("@cityCode", Optional(Text(profile, "cityCode"))),
|
||||
new("@cityName", Optional(Text(profile, "cityName"))),
|
||||
new("@districtCode", Optional(Text(profile, "districtCode"))),
|
||||
new("@districtName", Optional(Text(profile, "districtName"))),
|
||||
new("@address", Optional(Text(profile, "address"))),
|
||||
new("@schoolId", Optional(Text(profile, "schoolId"))),
|
||||
new("@classId", Optional(Text(profile, "classId"))),
|
||||
new("@emergencyContact", Optional(Text(profile, "emergencyContact"))),
|
||||
new("@emergencyPhone", Optional(Text(profile, "emergencyPhone"))),
|
||||
new("@status", Text(profile, "status")),
|
||||
new("@reviewNote", Optional(Text(profile, "reviewNote"))),
|
||||
new("@nativePlace", Optional(Text(profile, "nativePlace"))),
|
||||
new("@birthDate", Optional(Text(profile, "birthDate"))),
|
||||
new("@ethnicity", Optional(Text(profile, "ethnicity"))),
|
||||
new("@postalCode", Optional(Text(profile, "postalCode"))),
|
||||
new("@guardianName", Optional(Text(profile, "guardianName"))),
|
||||
new("@guardianPhone", Optional(Text(profile, "guardianPhone"))),
|
||||
new("@specialtyCategory", Optional(Text(profile, "specialtyCategory"))),
|
||||
new("@specialtyType", Optional(Text(profile, "specialtyType"))),
|
||||
new("@specialtyTypes", profile["specialtyTypes"]?.ToJsonString() ?? "[]"),
|
||||
new("@specialtyCertificate", Optional(Text(profile, "specialtyCertificate"))),
|
||||
new("@policyEligibility", Optional(Text(profile, "policyEligibility"))),
|
||||
new("@profileCompleted", Boolean(profile, "profileCompleted") ? 1 : 0),
|
||||
new("@reviewedAt", Optional(Text(profile, "reviewedAt"))),
|
||||
new("@reviewerId", Optional(Text(profile, "reviewerId"))),
|
||||
new("@updatedAt", Text(profile, "updatedAt")),
|
||||
new("@id", Text(profile, "id"))
|
||||
};
|
||||
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ExecuteAsync(connection, transaction, sql, parameters, cancellationToken);
|
||||
await ExecuteAsync(connection, transaction,
|
||||
"UPDATE users SET display_name = @displayName WHERE id = @userId",
|
||||
[new("@displayName", displayName), new("@userId", Text(profile, "userId"))],
|
||||
cancellationToken);
|
||||
if (instance is not null && action is not null)
|
||||
{
|
||||
await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task CreateRegistrationAsync(
|
||||
CandidateRegistration registration,
|
||||
CandidateWorkflowInstance? instance,
|
||||
CandidateWorkflowAction? action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO registrations (
|
||||
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at,
|
||||
reviewed_at, review_note, registration_number, number_rule_id, feature_score
|
||||
) VALUES (
|
||||
@id, @userId, @examId, @status, @paymentStatus, @paidAt, @paidBy, @createdAt,
|
||||
@reviewedAt, @reviewNote, @registrationNumber, @numberRuleId, @featureScore
|
||||
)
|
||||
""";
|
||||
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
await ExecuteAsync(connection, transaction, sql,
|
||||
[
|
||||
new("@id", registration.Id),
|
||||
new("@userId", registration.UserId),
|
||||
new("@examId", registration.ExamId),
|
||||
new("@status", registration.Status),
|
||||
new("@paymentStatus", registration.PaymentStatus),
|
||||
new("@paidAt", registration.PaidAt),
|
||||
new("@paidBy", registration.PaidBy),
|
||||
new("@createdAt", registration.CreatedAt),
|
||||
new("@reviewedAt", registration.ReviewedAt),
|
||||
new("@reviewNote", Optional(registration.ReviewNote)),
|
||||
new("@registrationNumber", Optional(registration.RegistrationNumber)),
|
||||
new("@numberRuleId", registration.NumberRuleId),
|
||||
new("@featureScore", registration.FeatureScore)
|
||||
], cancellationToken);
|
||||
foreach (var subjectId in registration.SubjectIds)
|
||||
{
|
||||
await ExecuteAsync(connection, transaction,
|
||||
"INSERT INTO registration_subjects (registration_id, subject_id) VALUES (@registrationId, @subjectId)",
|
||||
[new("@registrationId", registration.Id), new("@subjectId", subjectId)],
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
if (instance is not null && action is not null)
|
||||
{
|
||||
await InsertWorkflowAsync(connection, transaction, instance, action, cancellationToken);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task InsertWorkflowAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
CandidateWorkflowInstance instance,
|
||||
CandidateWorkflowAction action,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
const string instanceSql = """
|
||||
INSERT INTO workflow_instances (
|
||||
id, workflow_id, business_type, business_id, status, current_step,
|
||||
assignee_id, created_at, completed_at
|
||||
) VALUES (
|
||||
@id, @workflowId, @businessType, @businessId, @status, @currentStep,
|
||||
@assigneeId, @createdAt, @completedAt
|
||||
)
|
||||
""";
|
||||
await ExecuteAsync(connection, transaction, instanceSql,
|
||||
[
|
||||
new("@id", instance.Id),
|
||||
new("@workflowId", instance.WorkflowId),
|
||||
new("@businessType", instance.BusinessType),
|
||||
new("@businessId", instance.BusinessId),
|
||||
new("@status", instance.Status),
|
||||
new("@currentStep", instance.CurrentStep),
|
||||
new("@assigneeId", instance.AssigneeId),
|
||||
new("@createdAt", instance.CreatedAt),
|
||||
new("@completedAt", instance.CompletedAt)
|
||||
], cancellationToken);
|
||||
const string actionSql = """
|
||||
INSERT INTO workflow_actions (
|
||||
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
|
||||
) VALUES (
|
||||
@id, @instanceId, @actorId, @action, @note, @fromAssigneeId, @toAssigneeId, @createdAt
|
||||
)
|
||||
""";
|
||||
await ExecuteAsync(connection, transaction, actionSql,
|
||||
[
|
||||
new("@id", action.Id),
|
||||
new("@instanceId", action.InstanceId),
|
||||
new("@actorId", action.ActorId),
|
||||
new("@action", action.Action),
|
||||
new("@note", Optional(action.Note)),
|
||||
new("@fromAssigneeId", action.FromAssigneeId),
|
||||
new("@toAssigneeId", action.ToAssigneeId),
|
||||
new("@createdAt", action.CreatedAt)
|
||||
], cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task ExecuteAsync(
|
||||
DbConnection connection,
|
||||
DbTransaction transaction,
|
||||
string sql,
|
||||
IReadOnlyList<QueryParameter> parameters,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = CreateCommand(connection, sql, parameters, transaction);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static DbCommand CreateCommand(
|
||||
DbConnection connection,
|
||||
string sql,
|
||||
IReadOnlyList<QueryParameter> parameters,
|
||||
DbTransaction? transaction = null)
|
||||
{
|
||||
var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.Transaction = transaction;
|
||||
foreach (var item in parameters)
|
||||
{
|
||||
var parameter = command.CreateParameter();
|
||||
parameter.ParameterName = item.Name;
|
||||
parameter.Value = item.Value ?? DBNull.Value;
|
||||
command.Parameters.Add(parameter);
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
|
||||
private static string Text(JsonObject value, string property) => value[property]?.ToString() ?? string.Empty;
|
||||
|
||||
private static bool Boolean(JsonObject value, string property) => value[property]?.GetValue<bool>() == true;
|
||||
|
||||
private static object? Optional(string? value) => string.IsNullOrEmpty(value) ? null : value;
|
||||
|
||||
private static string ReadString(DbDataReader reader, string name) =>
|
||||
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
|
||||
private sealed record QueryParameter(string Name, object? Value);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Eis.Infrastructure.Candidate;
|
||||
|
||||
internal sealed record ResolvedRegion(
|
||||
string ProvinceCode,
|
||||
string ProvinceName,
|
||||
string CityCode,
|
||||
string CityName,
|
||||
string DistrictCode,
|
||||
string DistrictName);
|
||||
|
||||
internal sealed class RegionCatalog
|
||||
{
|
||||
private const string Marker = "export const chinaRegions = ";
|
||||
private readonly IReadOnlyDictionary<string, Province> _provinces;
|
||||
|
||||
public RegionCatalog()
|
||||
{
|
||||
var assembly = typeof(RegionCatalog).Assembly;
|
||||
var resourceName = assembly.GetManifestResourceNames()
|
||||
.Single(name => name.EndsWith("china-regions.mjs", StringComparison.Ordinal));
|
||||
using var stream = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException("无法读取内嵌行政区划数据");
|
||||
using var reader = new StreamReader(stream);
|
||||
var source = reader.ReadToEnd();
|
||||
var markerIndex = source.IndexOf(Marker, StringComparison.Ordinal);
|
||||
if (markerIndex < 0)
|
||||
{
|
||||
throw new InvalidOperationException("行政区划数据格式无效");
|
||||
}
|
||||
|
||||
var json = source[(markerIndex + Marker.Length)..].Trim();
|
||||
if (json.EndsWith(';'))
|
||||
{
|
||||
json = json[..^1];
|
||||
}
|
||||
|
||||
var provinces = JsonSerializer.Deserialize<Province[]>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
}) ?? throw new InvalidOperationException("行政区划数据为空");
|
||||
_provinces = provinces.ToDictionary(item => item.Code, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public ResolvedRegion? Resolve(string? provinceCode, string? cityCode, string? districtCode)
|
||||
{
|
||||
var provinceKey = (provinceCode ?? string.Empty).Trim();
|
||||
var cityKey = (cityCode ?? string.Empty).Trim();
|
||||
var districtKey = (districtCode ?? string.Empty).Trim();
|
||||
if (!_provinces.TryGetValue(provinceKey, out var province))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var city = province.Cities.FirstOrDefault(item => item.Code == cityKey);
|
||||
var district = city?.Districts.FirstOrDefault(item => item.Code == districtKey);
|
||||
return city is null || district is null
|
||||
? null
|
||||
: new ResolvedRegion(province.Code, province.Name, city.Code, city.Name, district.Code, district.Name);
|
||||
}
|
||||
|
||||
private sealed class Province
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required City[] Cities { get; init; }
|
||||
}
|
||||
|
||||
private sealed class City
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required District[] Districts { get; init; }
|
||||
}
|
||||
|
||||
private sealed class District
|
||||
{
|
||||
public required string Code { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -43,8 +43,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<AuthenticationRepository>();
|
||||
services.AddScoped<IAuthenticationService, AuthenticationService>();
|
||||
services.AddSingleton(candidateMigrationOptions);
|
||||
services.AddSingleton<RegionCatalog>();
|
||||
services.AddScoped<CandidateReadSnapshotLoader>();
|
||||
services.AddScoped<ICandidateQueryService, CandidateQueryService>();
|
||||
services.AddScoped<CandidateWriteRepository>();
|
||||
services.AddScoped<ICandidateService, CandidateService>();
|
||||
services.AddScoped<IPublicQueryService, PublicQueryService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,7 @@
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" />
|
||||
<PackageReference Include="StackExchange.Redis" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\data\china-regions.mjs" Link="Data\china-regions.mjs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
+22
-8
@@ -3,47 +3,61 @@ using Eis.Infrastructure.Candidate;
|
||||
|
||||
namespace Eis.Web.Candidate;
|
||||
|
||||
public static class NativeCandidateReadEndpoints
|
||||
public static class NativeCandidateEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapNativeCandidateReadEndpoints(
|
||||
public static IEndpointRouteBuilder MapNativeCandidateEndpoints(
|
||||
this IEndpointRouteBuilder endpoints,
|
||||
CandidateMigrationOptions options)
|
||||
{
|
||||
if (!options.NativeReadEnabled)
|
||||
if (!options.NativeEnabled)
|
||||
{
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
endpoints.MapGet("/api/candidate/dashboard", async (
|
||||
HttpContext context,
|
||||
ICandidateQueryService service,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.GetDashboardAsync(SessionToken(context), cancellationToken)));
|
||||
endpoints.MapGet("/api/candidate/notices", async (
|
||||
HttpContext context,
|
||||
ICandidateQueryService service,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.GetNoticesAsync(SessionToken(context), cancellationToken)));
|
||||
endpoints.MapGet("/api/candidate/profile", async (
|
||||
HttpContext context,
|
||||
ICandidateQueryService service,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.GetProfileAsync(SessionToken(context), cancellationToken)));
|
||||
endpoints.MapPut("/api/candidate/profile", async (
|
||||
HttpContext context,
|
||||
System.Text.Json.Nodes.JsonObject request,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.UpdateProfileAsync(SessionToken(context), request, cancellationToken)));
|
||||
endpoints.MapGet("/api/candidate/exams", async (
|
||||
HttpContext context,
|
||||
ICandidateQueryService service,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.GetExamsAsync(SessionToken(context), cancellationToken)));
|
||||
endpoints.MapGet("/api/candidate/registrations", async (
|
||||
HttpContext context,
|
||||
ICandidateQueryService service,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.GetRegistrationsAsync(SessionToken(context), cancellationToken)));
|
||||
endpoints.MapPost("/api/candidate/registrations", async (
|
||||
HttpContext context,
|
||||
System.Text.Json.Nodes.JsonObject request,
|
||||
ICandidateService service,
|
||||
CancellationToken cancellationToken) => ToResult(
|
||||
context,
|
||||
await service.CreateRegistrationAsync(SessionToken(context), request, cancellationToken)));
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
@@ -37,7 +37,7 @@ var authenticationOptions = AuthenticationOptions.FromEnvironment(
|
||||
builder.Environment.IsProduction(),
|
||||
builder.Configuration.GetValue<bool>("AuthenticationMigration:NativeEnabled"));
|
||||
var candidateMigrationOptions = CandidateMigrationOptions.FromEnvironment(
|
||||
builder.Configuration.GetValue<bool>("CandidateMigration:NativeReadEnabled"),
|
||||
builder.Configuration.GetValue<bool>("CandidateMigration:NativeEnabled"),
|
||||
authenticationOptions.NativeEnabled,
|
||||
authenticationOptions.SharesLegacySessions);
|
||||
builder.Services.AddEisInfrastructure(
|
||||
@@ -81,9 +81,9 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
||||
},
|
||||
candidate = new
|
||||
{
|
||||
nativeReadEnabled = candidateMigrationOptions.NativeReadEnabled,
|
||||
nativeRoutes = candidateMigrationOptions.NativeReadEnabled
|
||||
? new[] { "dashboard", "notices", "profile", "exams", "registrations" }
|
||||
nativeEnabled = candidateMigrationOptions.NativeEnabled,
|
||||
nativeRoutes = candidateMigrationOptions.NativeEnabled
|
||||
? new[] { "GET dashboard", "GET notices", "GET/PUT profile", "GET exams", "GET/POST registrations" }
|
||||
: []
|
||||
},
|
||||
features = MigrationFeatureCatalog.Current(authenticationOptions.NativeEnabled)
|
||||
@@ -92,7 +92,7 @@ app.MapGet("/health/migration", async (LegacyApiProxy proxy, CancellationToken c
|
||||
|
||||
app.MapNativePublicEndpoints();
|
||||
app.MapNativeAuthenticationEndpoints(authenticationOptions);
|
||||
app.MapNativeCandidateReadEndpoints(candidateMigrationOptions);
|
||||
app.MapNativeCandidateEndpoints(candidateMigrationOptions);
|
||||
|
||||
string[] methods =
|
||||
[
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"NativeEnabled": false
|
||||
},
|
||||
"CandidateMigration": {
|
||||
"NativeReadEnabled": false
|
||||
"NativeEnabled": false
|
||||
},
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
|
||||
Reference in New Issue
Block a user