Build and publish packages and Docker images / Test, build and publish (push) Successful in 43m50s
590 lines
26 KiB
C#
590 lines
26 KiB
C#
using System.Data.Common;
|
|
using System.Globalization;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Nodes;
|
|
using Eis.Infrastructure.Data;
|
|
|
|
namespace Eis.Infrastructure.Authentication;
|
|
|
|
internal sealed class AuthenticationUser
|
|
{
|
|
public required string Id { get; init; }
|
|
|
|
public required string Username { get; init; }
|
|
|
|
public required string PasswordHash { get; set; }
|
|
|
|
public required string Role { get; init; }
|
|
|
|
public string? AdminLevel { get; init; }
|
|
|
|
public string? SchoolId { get; init; }
|
|
|
|
public string? ClassId { get; init; }
|
|
|
|
public bool Active { get; init; }
|
|
|
|
public bool MustChangePassword { get; set; }
|
|
|
|
public bool TotpEnabled { get; set; }
|
|
|
|
public string? TotpSecretEncrypted { get; set; }
|
|
|
|
public IReadOnlyList<string> TotpRecoveryCodes { get; set; } = [];
|
|
|
|
public long? TotpLastUsedStep { get; set; }
|
|
|
|
public string? ArchivedAt { get; init; }
|
|
|
|
public required string DisplayName { get; init; }
|
|
|
|
public string? CandidateNumber { get; init; }
|
|
}
|
|
|
|
internal sealed record RegistrationCreationResult(int StatusCode, string? RegistrationNumber, string? ErrorMessage);
|
|
|
|
internal sealed class AuthenticationRepository(IRelationalConnectionFactory connectionFactory)
|
|
{
|
|
private const string UserColumns = """
|
|
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
|
|
must_change_password, totp_enabled, totp_secret_encrypted, totp_recovery_codes,
|
|
totp_last_used_step, archived_at, display_name
|
|
""";
|
|
|
|
public Task<AuthenticationUser?> FindUserByAccountAsync(string account, CancellationToken cancellationToken) =>
|
|
QueryUserAsync(
|
|
$"SELECT {UserColumns} FROM users WHERE LOWER(username) = @account OR LOWER(COALESCE(candidate_number, '')) = @account LIMIT 1",
|
|
[new("@account", account)],
|
|
cancellationToken);
|
|
|
|
public Task<AuthenticationUser?> FindUserByIdAsync(string id, CancellationToken cancellationToken) =>
|
|
QueryUserAsync(
|
|
$"SELECT {UserColumns} FROM users WHERE id = @id LIMIT 1",
|
|
[new("@id", id)],
|
|
cancellationToken);
|
|
|
|
public async Task<RegistrationCreationResult> CreateSelfRegisteredCandidateAsync(
|
|
string name,
|
|
string gender,
|
|
string schoolId,
|
|
string classId,
|
|
string passwordHash,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
try
|
|
{
|
|
var registrationEnabled = await ScalarAsync(
|
|
connection,
|
|
transaction,
|
|
"SELECT self_registration_enabled FROM schema_metadata WHERE id = 1",
|
|
[],
|
|
cancellationToken);
|
|
if (registrationEnabled is null || !Convert.ToBoolean(registrationEnabled, CultureInfo.InvariantCulture))
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return new RegistrationCreationResult(
|
|
403,
|
|
null,
|
|
"当前未开放自主注册,请使用学校下发的报名号和初始密码登录");
|
|
}
|
|
|
|
var schoolName = await ScalarAsync(
|
|
connection,
|
|
transaction,
|
|
"SELECT name FROM schools WHERE id = @schoolId AND active = 1 AND is_source_school = 1",
|
|
[new("@schoolId", schoolId)],
|
|
cancellationToken);
|
|
var className = await ScalarAsync(
|
|
connection,
|
|
transaction,
|
|
"SELECT name FROM school_classes WHERE id = @classId AND school_id = @schoolId AND active = 1",
|
|
[new("@classId", classId), new("@schoolId", schoolId)],
|
|
cancellationToken);
|
|
if (schoolName is null || className is null)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return new RegistrationCreationResult(400, null, "请选择有效的学校和班级");
|
|
}
|
|
|
|
const string ruleSql = """
|
|
SELECT id, `separator` FROM number_rules
|
|
WHERE active = 1 ORDER BY updated_at DESC, id LIMIT 1
|
|
""";
|
|
string? ruleId = null;
|
|
var separator = string.Empty;
|
|
await using (var ruleCommand = CreateCommand(connection, ruleSql, [], transaction))
|
|
await using (var reader = await ruleCommand.ExecuteReaderAsync(cancellationToken))
|
|
{
|
|
if (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
ruleId = ReadString(reader, "id");
|
|
separator = ReadString(reader, "separator");
|
|
}
|
|
}
|
|
|
|
if (ruleId is null)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return new RegistrationCreationResult(409, null, "尚未配置可用的报名号生成规则");
|
|
}
|
|
|
|
var segments = new List<NumberRuleSegment>();
|
|
const string segmentSql = """
|
|
SELECT type, value, width FROM number_rule_segments
|
|
WHERE rule_id = @ruleId ORDER BY position, id
|
|
""";
|
|
await using (var segmentCommand = CreateCommand(connection, segmentSql, [new("@ruleId", ruleId)], transaction))
|
|
await using (var reader = await segmentCommand.ExecuteReaderAsync(cancellationToken))
|
|
{
|
|
while (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
segments.Add(new NumberRuleSegment(
|
|
ReadString(reader, "type"),
|
|
ReadOptionalString(reader, "value") ?? string.Empty,
|
|
Convert.ToInt32(reader.GetValue(reader.GetOrdinal("width")), CultureInfo.InvariantCulture)));
|
|
}
|
|
}
|
|
|
|
if (segments.Count == 0)
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
return new RegistrationCreationResult(409, null, "尚未配置可用的报名号生成规则");
|
|
}
|
|
|
|
var year = DateTime.Now.Year.ToString(CultureInfo.InvariantCulture);
|
|
var schoolCodeValue = await ScalarAsync(
|
|
connection,
|
|
transaction,
|
|
"SELECT code FROM schools WHERE id = @schoolId",
|
|
[new("@schoolId", schoolId)],
|
|
cancellationToken);
|
|
var schoolCode = Convert.ToString(schoolCodeValue, CultureInfo.InvariantCulture) ?? string.Empty;
|
|
var prefixParts = segments
|
|
.Where(segment => segment.Type != "sequence")
|
|
.Select(segment => segment.Type switch
|
|
{
|
|
"year" => year,
|
|
"school_code" => schoolCode,
|
|
_ => string.Empty
|
|
})
|
|
.Where(value => value.Length > 0);
|
|
var prefix = string.Join(separator, prefixParts);
|
|
var existingNumbers = new List<string>();
|
|
await using (var numberCommand = CreateCommand(
|
|
connection,
|
|
"SELECT candidate_number FROM users WHERE role = 'candidate' AND candidate_number IS NOT NULL",
|
|
[],
|
|
transaction))
|
|
await using (var reader = await numberCommand.ExecuteReaderAsync(cancellationToken))
|
|
{
|
|
while (await reader.ReadAsync(cancellationToken))
|
|
{
|
|
existingNumbers.Add(ReadString(reader, "candidate_number"));
|
|
}
|
|
}
|
|
|
|
var sequence = existingNumbers.LongCount(number =>
|
|
prefix.Length == 0 || number.StartsWith(prefix, StringComparison.Ordinal)) + 1;
|
|
var parts = segments.Select(segment => segment.Type switch
|
|
{
|
|
"year" => LastCharacters(year, Math.Max(2, segment.Width == 0 ? 4 : segment.Width)),
|
|
"school_code" => schoolCode.Length > 0 ? schoolCode : "NOSCHOOL",
|
|
"gender" => gender == "男" ? "M" : gender == "女" ? "F" : "X",
|
|
"sequence" => sequence.ToString(CultureInfo.InvariantCulture).PadLeft(Math.Max(1, segment.Width == 0 ? 4 : segment.Width), '0'),
|
|
_ => Clean(segment.Value, 20).ToUpperInvariant()
|
|
});
|
|
var registrationNumber = string.Join(separator, parts);
|
|
var userId = Uid("usr");
|
|
var profileId = Uid("profile");
|
|
var now = DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture);
|
|
|
|
const string userSql = """
|
|
INSERT INTO users (
|
|
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id,
|
|
active, must_change_password, archived_at, archived_by, display_name, created_at
|
|
) VALUES (
|
|
@id, @username, @candidateNumber, @passwordHash, 'candidate', NULL, NULL, NULL,
|
|
1, 0, NULL, NULL, @displayName, @createdAt
|
|
)
|
|
""";
|
|
await using (var userCommand = CreateCommand(connection, userSql,
|
|
[
|
|
new("@id", userId),
|
|
new("@username", registrationNumber),
|
|
new("@candidateNumber", registrationNumber),
|
|
new("@passwordHash", passwordHash),
|
|
new("@displayName", name),
|
|
new("@createdAt", now)
|
|
], transaction))
|
|
{
|
|
await userCommand.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
const string profileSql = """
|
|
INSERT INTO candidate_profiles (
|
|
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
|
address, emergency_contact, emergency_phone, native_place, birth_date, ethnicity,
|
|
postal_code, guardian_name, guardian_phone, profile_completed, status, review_note, updated_at
|
|
) VALUES (
|
|
@id, @userId, @name, @gender, @idNumber, '', NULL, @school, @grade, @schoolId, @classId,
|
|
NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, 'pending', NULL, @updatedAt
|
|
)
|
|
""";
|
|
await using (var profileCommand = CreateCommand(connection, profileSql,
|
|
[
|
|
new("@id", profileId),
|
|
new("@userId", userId),
|
|
new("@name", name),
|
|
new("@gender", gender),
|
|
new("@idNumber", $"PENDING-{userId}"),
|
|
new("@school", Convert.ToString(schoolName, CultureInfo.InvariantCulture)),
|
|
new("@grade", Convert.ToString(className, CultureInfo.InvariantCulture)),
|
|
new("@schoolId", schoolId),
|
|
new("@classId", classId),
|
|
new("@updatedAt", now)
|
|
], transaction))
|
|
{
|
|
await profileCommand.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
await transaction.CommitAsync(cancellationToken);
|
|
return new RegistrationCreationResult(201, registrationNumber, null);
|
|
}
|
|
catch
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<JsonObject?> GetCandidateProfileAsync(string userId, CancellationToken cancellationToken)
|
|
{
|
|
const string sql = """
|
|
SELECT id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
|
|
province_code, province_name, city_code, city_name, district_code, district_name, address,
|
|
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code,
|
|
guardian_name, guardian_phone, specialty_category, specialty_type, specialty_types,
|
|
specialty_certificate, policy_eligibility, profile_completed, status, review_note,
|
|
reviewed_at, reviewer_id, updated_at
|
|
FROM candidate_profiles WHERE user_id = @userId LIMIT 1
|
|
""";
|
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
|
await using var command = CreateCommand(connection, sql, [new("@userId", userId)]);
|
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
if (!await reader.ReadAsync(cancellationToken))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new JsonObject
|
|
{
|
|
["id"] = ReadString(reader, "id"),
|
|
["userId"] = ReadString(reader, "user_id"),
|
|
["name"] = ReadString(reader, "name"),
|
|
["gender"] = ReadOptionalString(reader, "gender") ?? string.Empty,
|
|
["idNumber"] = ReadString(reader, "id_number"),
|
|
["phone"] = ReadString(reader, "phone"),
|
|
["email"] = ReadOptionalString(reader, "email") ?? string.Empty,
|
|
["school"] = ReadOptionalString(reader, "school") ?? string.Empty,
|
|
["grade"] = ReadOptionalString(reader, "grade") ?? string.Empty,
|
|
["schoolId"] = JsonValue.Create(ReadOptionalString(reader, "school_id")),
|
|
["classId"] = JsonValue.Create(ReadOptionalString(reader, "class_id")),
|
|
["provinceCode"] = ReadOptionalString(reader, "province_code") ?? string.Empty,
|
|
["provinceName"] = ReadOptionalString(reader, "province_name") ?? string.Empty,
|
|
["cityCode"] = ReadOptionalString(reader, "city_code") ?? string.Empty,
|
|
["cityName"] = ReadOptionalString(reader, "city_name") ?? string.Empty,
|
|
["districtCode"] = ReadOptionalString(reader, "district_code") ?? string.Empty,
|
|
["districtName"] = ReadOptionalString(reader, "district_name") ?? string.Empty,
|
|
["address"] = ReadOptionalString(reader, "address") ?? string.Empty,
|
|
["emergencyContact"] = ReadOptionalString(reader, "emergency_contact") ?? string.Empty,
|
|
["emergencyPhone"] = ReadOptionalString(reader, "emergency_phone") ?? string.Empty,
|
|
["nativePlace"] = ReadOptionalString(reader, "native_place") ?? string.Empty,
|
|
["birthDate"] = ReadOptionalString(reader, "birth_date") ?? string.Empty,
|
|
["ethnicity"] = ReadOptionalString(reader, "ethnicity") ?? string.Empty,
|
|
["postalCode"] = ReadOptionalString(reader, "postal_code") ?? string.Empty,
|
|
["guardianName"] = ReadOptionalString(reader, "guardian_name") ?? string.Empty,
|
|
["guardianPhone"] = ReadOptionalString(reader, "guardian_phone") ?? string.Empty,
|
|
["specialtyCategory"] = ReadOptionalString(reader, "specialty_category") ?? string.Empty,
|
|
["specialtyType"] = ReadOptionalString(reader, "specialty_type") ?? string.Empty,
|
|
["specialtyTypes"] = ParseArray(ReadOptionalString(reader, "specialty_types")),
|
|
["specialtyCertificate"] = ReadOptionalString(reader, "specialty_certificate") ?? string.Empty,
|
|
["policyEligibility"] = ReadOptionalString(reader, "policy_eligibility") ?? string.Empty,
|
|
["profileCompleted"] = ReadBoolean(reader, "profile_completed"),
|
|
["status"] = ReadString(reader, "status"),
|
|
["reviewNote"] = ReadOptionalString(reader, "review_note") ?? string.Empty,
|
|
["reviewedAt"] = JsonValue.Create(ReadOptionalString(reader, "reviewed_at")),
|
|
["reviewerId"] = JsonValue.Create(ReadOptionalString(reader, "reviewer_id")),
|
|
["updatedAt"] = ReadString(reader, "updated_at")
|
|
};
|
|
}
|
|
|
|
public async Task<string> GetOrganizationNameAsync(CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
|
await using var command = CreateCommand(connection, "SELECT name FROM organization WHERE id = 1", []);
|
|
var value = await command.ExecuteScalarAsync(cancellationToken);
|
|
return value is null or DBNull ? "考试服务平台" : Clean(Convert.ToString(value, CultureInfo.InvariantCulture), 80);
|
|
}
|
|
|
|
public async Task<string> GetAdminScopeLabelAsync(AuthenticationUser user, CancellationToken cancellationToken)
|
|
{
|
|
if ((user.AdminLevel ?? "super") == "super")
|
|
{
|
|
return "全部学校与班级";
|
|
}
|
|
|
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
|
var school = await ScalarStringAsync(connection, "SELECT name FROM schools WHERE id = @id", user.SchoolId, cancellationToken)
|
|
?? "未绑定学校";
|
|
if (user.AdminLevel == "school")
|
|
{
|
|
return school;
|
|
}
|
|
|
|
var schoolClass = await ScalarStringAsync(connection, "SELECT name FROM school_classes WHERE id = @id", user.ClassId, cancellationToken)
|
|
?? "未绑定班级";
|
|
return $"{school} · {schoolClass}";
|
|
}
|
|
|
|
public Task UpdatePasswordAsync(AuthenticationUser user, string logId, string action, string detail, CancellationToken cancellationToken) =>
|
|
ExecuteUserUpdateWithAuditAsync(
|
|
"UPDATE users SET password_hash = @passwordHash, must_change_password = @mustChangePassword WHERE id = @id",
|
|
[
|
|
new("@passwordHash", user.PasswordHash),
|
|
new("@mustChangePassword", user.MustChangePassword ? 1 : 0),
|
|
new("@id", user.Id)
|
|
],
|
|
user,
|
|
logId,
|
|
action,
|
|
detail,
|
|
cancellationToken);
|
|
|
|
public Task UpdateTotpSecurityAsync(
|
|
AuthenticationUser user,
|
|
string? logId,
|
|
string? action,
|
|
string? detail,
|
|
CancellationToken cancellationToken) => ExecuteUserUpdateWithAuditAsync(
|
|
"""
|
|
UPDATE users SET totp_enabled = @enabled, totp_secret_encrypted = @secret,
|
|
totp_recovery_codes = @recoveryCodes, totp_last_used_step = @lastUsedStep
|
|
WHERE id = @id
|
|
""",
|
|
[
|
|
new("@enabled", user.TotpEnabled ? 1 : 0),
|
|
new("@secret", user.TotpSecretEncrypted),
|
|
new("@recoveryCodes", JsonSerializer.Serialize(user.TotpRecoveryCodes)),
|
|
new("@lastUsedStep", user.TotpLastUsedStep),
|
|
new("@id", user.Id)
|
|
],
|
|
user,
|
|
logId,
|
|
action,
|
|
detail,
|
|
cancellationToken);
|
|
|
|
private async Task<AuthenticationUser?> QueryUserAsync(
|
|
string sql,
|
|
IReadOnlyList<SqlParameterValue> parameters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
|
await using var command = CreateCommand(connection, sql, parameters);
|
|
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
|
return await reader.ReadAsync(cancellationToken) ? ReadUser(reader) : null;
|
|
}
|
|
|
|
private async Task ExecuteUserUpdateWithAuditAsync(
|
|
string updateSql,
|
|
IReadOnlyList<SqlParameterValue> updateParameters,
|
|
AuthenticationUser user,
|
|
string? logId,
|
|
string? action,
|
|
string? detail,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var connection = await connectionFactory.OpenAsync(cancellationToken);
|
|
await using var transaction = await connection.BeginTransactionAsync(cancellationToken);
|
|
try
|
|
{
|
|
await using (var update = CreateCommand(connection, updateSql, updateParameters, transaction))
|
|
{
|
|
await update.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
if (logId is not null && action is not null && detail is not null)
|
|
{
|
|
const string auditSql = """
|
|
INSERT INTO audit_logs (id, actor_id, action, detail, created_at)
|
|
VALUES (@id, @actorId, @action, @detail, @createdAt)
|
|
""";
|
|
await using var audit = CreateCommand(connection, auditSql,
|
|
[
|
|
new("@id", logId),
|
|
new("@actorId", user.Id),
|
|
new("@action", action),
|
|
new("@detail", detail),
|
|
new("@createdAt", DateTimeOffset.UtcNow.ToString("O", CultureInfo.InvariantCulture))
|
|
], transaction);
|
|
await audit.ExecuteNonQueryAsync(cancellationToken);
|
|
}
|
|
|
|
await transaction.CommitAsync(cancellationToken);
|
|
}
|
|
catch
|
|
{
|
|
await transaction.RollbackAsync(cancellationToken);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
private static AuthenticationUser ReadUser(DbDataReader reader) => new()
|
|
{
|
|
Id = ReadString(reader, "id"),
|
|
Username = ReadString(reader, "username"),
|
|
CandidateNumber = ReadOptionalString(reader, "candidate_number"),
|
|
PasswordHash = ReadString(reader, "password_hash"),
|
|
Role = ReadString(reader, "role"),
|
|
AdminLevel = ReadOptionalString(reader, "admin_level") ?? (ReadString(reader, "role") == "admin" ? "super" : null),
|
|
SchoolId = ReadOptionalString(reader, "school_id"),
|
|
ClassId = ReadOptionalString(reader, "class_id"),
|
|
Active = ReadBoolean(reader, "active"),
|
|
MustChangePassword = ReadBoolean(reader, "must_change_password"),
|
|
TotpEnabled = ReadBoolean(reader, "totp_enabled"),
|
|
TotpSecretEncrypted = ReadOptionalString(reader, "totp_secret_encrypted"),
|
|
TotpRecoveryCodes = ParseStringArray(ReadOptionalString(reader, "totp_recovery_codes")),
|
|
TotpLastUsedStep = ReadNullableInt64(reader, "totp_last_used_step"),
|
|
ArchivedAt = ReadOptionalString(reader, "archived_at"),
|
|
DisplayName = ReadString(reader, "display_name")
|
|
};
|
|
|
|
private static async Task<string?> ScalarStringAsync(
|
|
DbConnection connection,
|
|
string sql,
|
|
string? id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (id is null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
await using var command = CreateCommand(connection, sql, [new("@id", id)]);
|
|
var value = await command.ExecuteScalarAsync(cancellationToken);
|
|
return value is null or DBNull ? null : Convert.ToString(value, CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static async Task<object?> ScalarAsync(
|
|
DbConnection connection,
|
|
DbTransaction transaction,
|
|
string sql,
|
|
IReadOnlyList<SqlParameterValue> parameters,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await using var command = CreateCommand(connection, sql, parameters, transaction);
|
|
var value = await command.ExecuteScalarAsync(cancellationToken);
|
|
return value is DBNull ? null : value;
|
|
}
|
|
|
|
private static DbCommand CreateCommand(
|
|
DbConnection connection,
|
|
string sql,
|
|
IReadOnlyList<SqlParameterValue> 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 ReadString(DbDataReader reader, string name) =>
|
|
Convert.ToString(reader.GetValue(reader.GetOrdinal(name)), CultureInfo.InvariantCulture) ?? string.Empty;
|
|
|
|
private static string? ReadOptionalString(DbDataReader reader, string name)
|
|
{
|
|
var ordinal = reader.GetOrdinal(name);
|
|
return reader.IsDBNull(ordinal) ? null : Convert.ToString(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static bool ReadBoolean(DbDataReader reader, string name)
|
|
{
|
|
var value = reader.GetValue(reader.GetOrdinal(name));
|
|
return value switch
|
|
{
|
|
bool boolean => boolean,
|
|
byte number => number != 0,
|
|
short number => number != 0,
|
|
int number => number != 0,
|
|
long number => number != 0,
|
|
_ => Convert.ToBoolean(value, CultureInfo.InvariantCulture)
|
|
};
|
|
}
|
|
|
|
private static long? ReadNullableInt64(DbDataReader reader, string name)
|
|
{
|
|
var ordinal = reader.GetOrdinal(name);
|
|
return reader.IsDBNull(ordinal) ? null : Convert.ToInt64(reader.GetValue(ordinal), CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static IReadOnlyList<string> ParseStringArray(string? value)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<string[]>(value ?? "[]") ?? [];
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static JsonArray ParseArray(string? value)
|
|
{
|
|
try
|
|
{
|
|
return JsonNode.Parse(value ?? "[]")?.AsArray() ?? [];
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private static string Clean(string? value, int maximum) => (value ?? string.Empty).Trim()[..Math.Min((value ?? string.Empty).Trim().Length, maximum)];
|
|
|
|
private static string LastCharacters(string value, int count) => value[Math.Max(0, value.Length - count)..];
|
|
|
|
private static string Uid(string prefix) =>
|
|
$"{prefix}_{ToBase36(DateTimeOffset.UtcNow.ToUnixTimeMilliseconds())}_{Convert.ToHexStringLower(System.Security.Cryptography.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 SqlParameterValue(string Name, object? Value);
|
|
|
|
private sealed record NumberRuleSegment(string Type, string Value, int Width);
|
|
}
|