PUT /api/candidate/profile

行政区划、学校班级、特长类别校验
证件号码唯一性
自动创建资料审核工作流
资料和用户显示名称原子更新

POST /api/candidate/registrations
资料审核状态、报名时间和科目校验
重复报名防护
自动选择负载最低的审批管理员
报名、科目和审批流原子写入
This commit is contained in:
2026-07-22 19:55:19 +08:00 Unverified
parent 07efa6813b
commit aecb0a04e7
16 changed files with 995 additions and 36 deletions
@@ -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);
}
}
@@ -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>