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
@@ -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);
}