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 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 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 FindUserByIdAsync(string id, CancellationToken cancellationToken) => QueryUserAsync( $"SELECT {UserColumns} FROM users WHERE id = @id LIMIT 1", [new("@id", id)], cancellationToken); public async Task 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(); 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(); 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 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 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 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 QueryUserAsync( string sql, IReadOnlyList 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 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 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 ScalarAsync( DbConnection connection, DbTransaction transaction, string sql, IReadOnlyList 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 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 ParseStringArray(string? value) { try { return JsonSerializer.Deserialize(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 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); }