行政区划、学校班级、特长类别校验 证件号码唯一性 自动创建资料审核工作流 资料和用户显示名称原子更新 POST /api/candidate/registrations 资料审核状态、报名时间和科目校验 重复报名防护 自动选择负载最低的审批管理员 报名、科目和审批流原子写入
313 lines
14 KiB
C#
313 lines
14 KiB
C#
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);
|
|
}
|