using Fitness_core_BE.DTOs; using Fitness_core_BE.DTOs.AccessDevice; using Fitness_core_BE.Exceptions; using Fitness_core_BE.Helpers; using Fitness_core_BE.Infra.UoW; using Fitness_core_BE.Interfaces; using Fitness_core_BE.Interfaces.Services; using Fitness_core_BE.Models; using System.Globalization; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace Fitness_core_BE.Services { /// /// Owns the cloud-side desired state for physical access. User identity is scoped by /// client in AccessProfile; terminal-specific state lives in AccessDeviceBinding. /// Fingerprint template bytes never leave the terminal. /// public class AccessDeviceService : IAccessDeviceService { private readonly IUnitOfWork _UoW; private readonly IAccessDeviceRepo _accessDeviceRepo; private readonly IAttendanceRepository _attendanceRepo; private readonly IDeviceCommandRepo _deviceCommandRepo; private readonly IPairingCodeRepo _pairingCodeRepo; public AccessDeviceService( IUnitOfWork unitOfWork, IAccessDeviceRepo accessDeviceRepo, IAttendanceRepository attendanceRepo, IDeviceCommandRepo deviceCommandRepo, IPairingCodeRepo pairingCodeRepo) { _UoW = unitOfWork; _accessDeviceRepo = accessDeviceRepo; _attendanceRepo = attendanceRepo; _deviceCommandRepo = deviceCommandRepo; _pairingCodeRepo = pairingCodeRepo; } public async Task AddDevice(object payload) { await _UoW.BeginAsync(); var data = DeserializePayload.Deserialize(payload); try { var device = new AccessDevice { DeviceId = Guid.NewGuid(), DeviceName = DataExtractor.GetRequiredString(data, "DeviceName"), FKClientId = DataExtractor.GetRequiredGuid(data, "FKClientId") }; await _accessDeviceRepo.AddDeviceAsync(device); await _UoW.CommitAsync(); return new { Message = "Device added", device.DeviceId }; } catch { await _UoW.RollbackAsync(); throw; } } public async Task RemoveDevice(object payload) { await _UoW.BeginAsync(); var data = DeserializePayload.Deserialize(payload); try { var clientId = DataExtractor.GetRequiredGuid(data, "clientId"); var result = await _accessDeviceRepo.RemoveDeviceAsync(clientId); await _UoW.CommitAsync(); return new { Message = $"Device {result.DeviceId} disabled", result.DeviceId }; } catch { await _UoW.RollbackAsync(); throw; } } public async Task GeneratePairingCode(object payload) { var data = DeserializePayload.Deserialize(payload); var clientId = DataExtractor.GetRequiredGuid(data, "clientId"); var pairingCode = new PairingCode { Id = Guid.NewGuid(), Code = GenerateShortCode(), FKClientId = clientId, ExpiresAt = DateTime.UtcNow.AddMinutes(15), IsUsed = false, CreatedAt = DateTime.UtcNow }; await _UoW.BeginAsync(); try { await _pairingCodeRepo.AddAsync(pairingCode); await _UoW.CommitAsync(); return new { pairingCode.Code, pairingCode.ExpiresAt }; } catch { await _UoW.RollbackAsync(); throw; } } /// /// Compatibility entry point for old admin callers. New flows create an /// AccessProfile automatically when the customer/staff record is created. /// public async Task AddUser(CustomerAccess requested) { var device = await _accessDeviceRepo.GetDeviceByDId(requested.FKDeviceId); var customer = await _accessDeviceRepo .GetCustomerByIdAsync(requested.FKCustomerId) ?? throw new NotFoundException("Customer not found."); await _UoW.BeginAsync(); try { var profile = await EnsureAccessProfileAsync( device.FKClientId, customer.FKUserId, "Customer", requested.Name ?? customer.CusName ?? string.Empty, queueProvisioning: false); var binding = await EnsureBindingAsync(profile, device); await QueueProfileCommandAsync(profile, binding, "UpsertPerson"); await _UoW.CommitAsync(); return new { accessProfileId = profile.AccessProfileId, accessNumber = binding.DeviceEmployeeNo, Message = "Access user queued for the on-prem agent." }; } catch { await _UoW.RollbackAsync(); throw; } } public async Task EnsureAccessProfileAsync( Guid clientId, Guid userId, string personType, string displayName, bool queueProvisioning = true) { var profile = await _accessDeviceRepo.GetAccessProfileAsync(clientId, userId); if (profile == null) { profile = new AccessProfile { FKClientId = clientId, FKUserId = userId, PersonType = personType, DisplayName = displayName, DesiredAccessState = "Enabled", DesiredVersion = 1, CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _accessDeviceRepo.AddAccessProfileAsync(profile); // AccessProfileId is the stable numeric terminal employee number. await _UoW.SaveChangesAsync(); } else { profile.PersonType = personType; profile.DisplayName = displayName; profile.UpdatedAt = DateTime.UtcNow; } if (queueProvisioning) await QueueProvisioningForActiveDevicesAsync(profile); return profile; } public async Task ProvisionProfilesToDeviceAsync(AccessDevice device) { var profiles = await _accessDeviceRepo .GetAccessProfilesByClientAsync(device.FKClientId); foreach (var profile in profiles) { var binding = await EnsureBindingAsync(profile, device); if (binding.ProvisionStatus != "Synced") await QueueProfileCommandAsync(profile, binding, "UpsertPerson"); } } public async Task SetUserAccessAsync(Guid clientId, Guid userId, bool enabled, string? reason) { await _UoW.BeginAsync(); try { var profile = await _accessDeviceRepo .GetAccessProfileAsync(clientId, userId) ?? throw new NotFoundException("No access profile exists for this user at this gym."); profile.DesiredAccessState = enabled ? "Enabled" : "Disabled"; profile.DesiredVersion++; profile.LockReason = enabled ? null : reason; profile.LockedAt = enabled ? null : DateTime.UtcNow; profile.UpdatedAt = DateTime.UtcNow; // Include terminals added after the person was originally onboarded. await QueueProvisioningForActiveDevicesAsync(profile); foreach (var binding in profile.DeviceBindings) { if (!await IsDeviceCommandableAsync(binding.FKDeviceId)) continue; binding.ProvisionStatus = "Pending"; binding.LastError = null; binding.UpdatedAt = DateTime.UtcNow; await SupersedePendingStateCommandsAsync(binding.AccessDeviceBindingId); await QueueProfileCommandAsync(profile, binding, "SetAccessState"); } await _UoW.CommitAsync(); return (await GetAccessStatusAsync(clientId, userId))!; } catch { await _UoW.RollbackAsync(); throw; } } public async Task SetCustomerAccessAsync( Guid clientId, Guid customerId, bool enabled, string? reason) { var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) ?? throw new NotFoundException("Customer not found."); var belongsToGym = await _accessDeviceRepo .IsActiveCustomerMemberAsync(clientId, customerId); if (!belongsToGym) throw new ForbiddenException("This customer does not belong to your gym."); var status = await SetUserAccessAsync(clientId, customer.FKUserId, enabled, reason); // A couple/group plan is one shared entitlement, so the whole party follows the // member that was acted on -- locking only the primary would leave the partner // badging in on a plan that is locked, and unlocking only the primary would leave // the partner stranded after the reason for the lock is gone. var coMemberIds = await _accessDeviceRepo .GetCoMembersOnSharedSubscriptionsAsync(clientId, customerId); foreach (var coMemberId in coMemberIds) { var coMember = await _accessDeviceRepo.GetCustomerByIdAsync(coMemberId); if (coMember == null) continue; // Best-effort: a co-member without an access profile yet (never onboarded to // a device) must not fail the lock that was actually requested. try { await SetUserAccessAsync(clientId, coMember.FKUserId, enabled, reason); } catch (NotFoundException) { } } return status; } public async Task SetCustomerFacePhotoAsync( Guid clientId, Guid customerId, string base64Jpeg) { // Validate before opening a transaction: a rejected photo should cost nothing. var photo = FacePhotoValidator.ValidateBase64(base64Jpeg); var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) ?? throw new NotFoundException("Customer not found."); var belongsToGym = await _accessDeviceRepo .IsActiveCustomerMemberAsync(clientId, customerId); if (!belongsToGym) throw new ForbiddenException("This customer does not belong to your gym."); await _UoW.BeginAsync(); try { var profile = await _accessDeviceRepo .GetAccessProfileAsync(clientId, customer.FKUserId) ?? throw new NotFoundException( "No access profile exists for this member at this gym."); profile.FacePhotoStatus = "Pending"; profile.FacePhotoError = null; profile.FacePhotoUpdatedAt = DateTime.UtcNow; profile.DesiredVersion++; profile.UpdatedAt = DateTime.UtcNow; // Include terminals added after the member was originally onboarded. await QueueProvisioningForActiveDevicesAsync(profile); var queued = 0; var payload = Convert.ToBase64String(photo.Bytes); foreach (var binding in profile.DeviceBindings) { if (!await IsDeviceCommandableAsync(binding.FKDeviceId)) continue; await SupersedePendingFacePhotoCommandsAsync(binding.AccessDeviceBindingId); await QueueFacePhotoCommandAsync(profile, binding, payload); queued++; } if (queued == 0) { // Nothing can carry the photo to a terminal, so don't leave the member // sitting on "Pending" forever with no command in flight. profile.FacePhotoStatus = "Failed"; profile.FacePhotoError = "No reachable access terminal is currently paired with this gym."; } await _UoW.CommitAsync(); return new { accessProfileId = profile.AccessProfileId, facePhotoStatus = profile.FacePhotoStatus, queuedToDevices = queued, width = photo.Width, height = photo.Height, sizeBytes = photo.Bytes.Length }; } catch { await _UoW.RollbackAsync(); throw; } } // Kept separate from QueueProfileCommandAsync: the face payload is large and is // deliberately purged after the terminal confirms, which the person/state commands // must never be. private async Task QueueFacePhotoCommandAsync( AccessProfile profile, AccessDeviceBinding binding, string base64Jpeg) { var payloadJson = JsonSerializer.Serialize(new { bindingId = binding.AccessDeviceBindingId, accessProfileId = profile.AccessProfileId, employeeNo = binding.DeviceEmployeeNo, name = profile.DisplayName, faceJpegBase64 = base64Jpeg, desiredVersion = profile.DesiredVersion }); await _deviceCommandRepo.AddAsync(new DeviceCommand { Id = Guid.NewGuid(), FKClientId = profile.FKClientId, FKDeviceId = binding.FKDeviceId, FKAccessDeviceBindingId = binding.AccessDeviceBindingId, CommandType = "UpsertFacePhoto", PayloadJson = payloadJson, DesiredVersion = profile.DesiredVersion, Status = "Pending", MaxAttempts = 3, NextAttemptAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow }); } // A newer photo replaces an older one that has not reached the device yet, so the // stale payload is dropped rather than left queued (and holding an image). private async Task SupersedePendingFacePhotoCommandsAsync(Guid bindingId) { var stale = await _deviceCommandRepo .GetPendingFacePhotoCommandsAsync(bindingId); foreach (var command in stale) { command.Status = "Superseded"; command.PayloadJson = string.Empty; command.ProcessedAt = DateTime.UtcNow; } } public async Task SetStaffAccessAsync( Guid clientId, Guid staffId, bool enabled, string? reason) { var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId) ?? throw new NotFoundException("Staff member not found in this gym."); return await SetUserAccessAsync(clientId, staff.FKUserId, enabled, reason); } public async Task WhiteListMember(Guid customerId) { var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); await SetUserAccessAsync(clientId, userId, enabled: true, reason: null); return true; } public async Task BlackListMember(Guid customerId) { var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); await SetUserAccessAsync(clientId, userId, enabled: false, reason: "Manual"); return true; } public async Task LockCustomer(Guid customerId, string reason) { var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId); if (customer == null) return false; var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); await SetUserAccessAsync(clientId, userId, enabled: false, reason); customer.IsLocked = true; customer.LockReason = reason; customer.LockedAt = DateTime.UtcNow; await _UoW.SaveChangesAsync(); return true; } public async Task UnlockCustomer(Guid customerId) { var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId); if (customer == null) return false; var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId); await SetUserAccessAsync(clientId, userId, enabled: true, reason: null); customer.IsLocked = false; customer.LockReason = null; customer.LockedAt = null; await _UoW.SaveChangesAsync(); return true; } public Task UpdateDeviceProfile(Customer customer) => throw new NotImplementedException(); public async Task SaveAttendanceRecords(Guid clientId, List attendanceList) { if (attendanceList.Count == 0) return 0; // The source key is stable across JSON DateTime kind conversion and // database round-trips. It also gives the database a final idempotency // guard when an agent retries after an ambiguous HTTP response. var keyedEvents = attendanceList .OrderBy(a => a.EventTime) .Select(dto => new { Dto = dto, SourceEventKey = BuildSourceEventKey( clientId, dto.DeviceId, dto.DeviceEmployeeNo, dto.EventTime, dto.EventType) }) .ToList(); var candidateKeys = keyedEvents .Select(e => e.SourceEventKey) .Distinct() .ToList(); var existingKeys = await _attendanceRepo.GetSourceEventKeysAsync(candidateKeys); var newRecords = new List(); var seenKeys = new HashSet(existingKeys); foreach (var keyedEvent in keyedEvents) { if (!seenKeys.Add(keyedEvent.SourceEventKey)) continue; var dto = keyedEvent.Dto; newRecords.Add(new Attendance { AttendanceId = Guid.NewGuid(), FKClientId = clientId, FKDeviceId = dto.DeviceId, FKCustomerId = dto.CustomerId, FKUserId = dto.UserId, FKAccessProfileId = dto.AccessProfileId, DeviceUserId = dto.DeviceUserId, DeviceEmployeeNo = dto.DeviceEmployeeNo, SourceEventKey = keyedEvent.SourceEventKey, EventTime = dto.EventTime, EventType = dto.EventType }); } if (newRecords.Count == 0) return 0; await _UoW.BeginAsync(); try { await _attendanceRepo.AddAttendancesAsync(newRecords); await _UoW.CommitAsync(); return newRecords.Count; } catch { await _UoW.RollbackAsync(); // The agent advances its per-device watermark only after an HTTP success. // Surface persistence failures so it retries instead of losing events. throw; } } public Task> GetUnlinkedFingerprintsAsync(Guid clientId) => _accessDeviceRepo.GetUnlinkedEnrollmentsByClientAsync(clientId); public async Task LinkFingerprintAsync( Guid clientId, Guid customerId, Guid unlinkedEnrollmentId) { var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) ?? throw new NotFoundException("Customer not found."); var belongsToGym = await _accessDeviceRepo .IsActiveCustomerMemberAsync(clientId, customerId); if (!belongsToGym) throw new ForbiddenException("This customer does not belong to your gym."); return await LinkDevicePersonAsync( clientId, customer.FKUserId, "Customer", customer.CusName ?? string.Empty, unlinkedEnrollmentId); } public async Task LinkDevicePersonAsync( Guid clientId, Guid userId, string personType, string displayName, Guid unlinkedEnrollmentId) { var enrollment = await _accessDeviceRepo .GetUnlinkedEnrollmentByIdAsync(unlinkedEnrollmentId) ?? throw new NotFoundException("Unlinked device person not found."); if (enrollment.LinkedAt != null) throw new BadRequestException("This device person has already been linked."); if (enrollment.AccessDevice.FKClientId != clientId) throw new ForbiddenException("This device person does not belong to your gym."); var employeeNo = NormalizeEmployeeNo(enrollment); var occupied = await _accessDeviceRepo.IsDeviceEmployeeNoOccupiedAsync( enrollment.FKDeviceId, employeeNo, userId); if (occupied) throw new BadRequestException($"Device user {employeeNo} is already linked to another person."); await _UoW.BeginAsync(); try { var profile = await EnsureAccessProfileAsync( clientId, userId, personType, displayName, queueProvisioning: false); var binding = await _accessDeviceRepo.GetBindingAsync( profile.AccessProfileId, enrollment.FKDeviceId); if (binding == null) { binding = new AccessDeviceBinding { AccessDeviceBindingId = Guid.NewGuid(), FKAccessProfileId = profile.AccessProfileId, FKDeviceId = enrollment.FKDeviceId, CreatedAt = DateTime.UtcNow }; await _accessDeviceRepo.AddBindingAsync(binding); profile.DeviceBindings.Add(binding); } binding.DeviceEmployeeNo = employeeNo; binding.DeviceReportedName = enrollment.DeviceReportedName; binding.FingerprintCount = enrollment.FingerprintCount; binding.ProvisionStatus = "Synced"; binding.ActualAccessState = IsBlocked(enrollment.DeviceUserType) ? "Disabled" : "Enabled"; binding.LastSyncedAt = DateTime.UtcNow; binding.LastError = null; binding.UpdatedAt = DateTime.UtcNow; var linkedAt = DateTime.UtcNow; var duplicateDiscoveries = await _accessDeviceRepo.GetDuplicateUnlinkedEnrollmentsAsync( enrollment.FKDeviceId, employeeNo, enrollment.DeviceUserId); foreach (var discovered in duplicateDiscoveries) { discovered.LinkedAt = linkedAt; discovered.FKAccessProfileId = profile.AccessProfileId; } // Reconcile all other active terminals for the same gym. await QueueProvisioningForActiveDevicesAsync(profile); await _UoW.CommitAsync(); return profile; } catch { await _UoW.RollbackAsync(); throw; } } public async Task LinkStaffFingerprintAsync( Guid clientId, Guid staffId, Guid unlinkedEnrollmentId) { var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId) ?? throw new NotFoundException("Staff member not found in this gym."); return await LinkDevicePersonAsync( clientId, staff.FKUserId, "Staff", staff.User?.UserName ?? string.Empty, unlinkedEnrollmentId); } public async Task GetAccessStatusAsync(Guid clientId, Guid userId) { var profile = await _accessDeviceRepo .GetAccessStatusProfileAsync(clientId, userId); if (profile == null) return null; var bindings = profile.DeviceBindings .Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null) .ToList(); var provisionStatus = bindings.Count == 0 ? "NoDevice" : bindings.Any(b => b.ProvisionStatus == "Failed") ? "Failed" : bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending"; var desired = profile.DesiredAccessState; var actual = bindings.Count == 0 ? "Unknown" : bindings.All(b => b.ActualAccessState == desired) ? desired : bindings.All(b => b.ActualAccessState == "Unknown") ? "Unknown" : "Mixed"; return new AccessStatusDto { AccessProfileId = profile.AccessProfileId, AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo ?? $"HF{profile.AccessProfileId}", PersonType = profile.PersonType, DesiredAccessState = desired, ProvisionStatus = provisionStatus, ActualAccessState = actual, FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount), LastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError, LastSyncedAt = bindings.Max(b => b.LastSyncedAt), DeviceCount = bindings.Count, SyncedDeviceCount = bindings.Count(b => b.ProvisionStatus == "Synced") }; } private async Task QueueProvisioningForActiveDevicesAsync(AccessProfile profile) { var devices = (await _accessDeviceRepo .GetDevicesByClientAsync(profile.FKClientId)) .Where(device => device.FKAgentId != null) .ToList(); foreach (var device in devices) { var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId) ?? await EnsureBindingAsync(profile, device); if (binding.ProvisionStatus != "Synced") await QueueProfileCommandAsync(profile, binding, "UpsertPerson"); } } private async Task EnsureBindingAsync(AccessProfile profile, AccessDevice device) { var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId) ?? await _accessDeviceRepo.GetBindingAsync( profile.AccessProfileId, device.DeviceId); if (binding != null) return binding; binding = new AccessDeviceBinding { AccessDeviceBindingId = Guid.NewGuid(), FKAccessProfileId = profile.AccessProfileId, FKDeviceId = device.DeviceId, // Namespace platform-managed terminal users away from locally-created // numeric ids, which prevents overwriting an unlinked person by collision. DeviceEmployeeNo = $"HF{profile.AccessProfileId}", ProvisionStatus = "Pending", ActualAccessState = "Unknown", CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow }; await _accessDeviceRepo.AddBindingAsync(binding); profile.DeviceBindings.Add(binding); return binding; } private async Task QueueProfileCommandAsync( AccessProfile profile, AccessDeviceBinding binding, string commandType) { var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync( binding.AccessDeviceBindingId, commandType, profile.DesiredVersion); if (alreadyQueued) return; var payloadJson = JsonSerializer.Serialize(new { bindingId = binding.AccessDeviceBindingId, accessProfileId = profile.AccessProfileId, employeeNo = binding.DeviceEmployeeNo, name = profile.DisplayName, enabled = profile.DesiredAccessState == "Enabled", desiredVersion = profile.DesiredVersion }); await _deviceCommandRepo.AddAsync(new DeviceCommand { Id = Guid.NewGuid(), FKClientId = profile.FKClientId, FKDeviceId = binding.FKDeviceId, FKAccessDeviceBindingId = binding.AccessDeviceBindingId, CommandType = commandType, PayloadJson = payloadJson, DesiredVersion = profile.DesiredVersion, Status = "Pending", MaxAttempts = 5, NextAttemptAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow }); } private async Task SupersedePendingStateCommandsAsync(Guid bindingId) { var stale = await _deviceCommandRepo .GetPendingStateCommandsAsync(bindingId); foreach (var command in stale) { command.Status = "Superseded"; command.ProcessedAt = DateTime.UtcNow; } } private Task IsDeviceCommandableAsync(Guid deviceId) => _accessDeviceRepo.IsDeviceCommandableAsync(deviceId); private async Task<(Guid clientId, Guid userId)> ResolveCustomerAccessOwnerAsync(Guid customerId) { var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId) ?? throw new NotFoundException("Customer not found."); var clientId = await _accessDeviceRepo .GetLatestActiveClientIdForCustomerAsync(customerId); if (!clientId.HasValue) throw new NotFoundException("Customer has no active gym membership."); return (clientId.Value, customer.FKUserId); } private static string NormalizeEmployeeNo(UnlinkedDeviceEnrollment enrollment) => !string.IsNullOrWhiteSpace(enrollment.DeviceEmployeeNo) ? enrollment.DeviceEmployeeNo : enrollment.DeviceUserId.ToString(); private static bool IsBlocked(string? userType) => string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase); private static string BuildSourceEventKey( Guid clientId, Guid deviceId, string deviceEmployeeNo, DateTime eventTime, string eventType) { // MySQL DATETIME does not preserve DateTime.Kind. Formatting only the // stored wall-clock fields keeps this key identical after reading it back. var normalizedTime = eventTime.ToString( "yyyy-MM-dd'T'HH:mm:ss.ffffff", CultureInfo.InvariantCulture); var source = string.Join( "|", clientId.ToString("D"), deviceId.ToString("D"), deviceEmployeeNo.Trim(), normalizedTime, eventType.Trim()); return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source))) .ToLowerInvariant(); } private static string GenerateShortCode(int length = 8) { const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; var bytes = RandomNumberGenerator.GetBytes(length); var chars = new char[length]; for (var i = 0; i < length; i++) chars[i] = alphabet[bytes[i] % alphabet.Length]; return new string(chars); } } }