using Microsoft.EntityFrameworkCore; using PnvPanel.Application.Auth; using PnvPanel.Application.Common.Concurrency; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; using PnvPanel.Domain.Telegram; namespace PnvPanel.Application.Telegram; /// /// Формально Query, но при первом наблюдении Approved-статуса атомарно "забирает" вход: /// выпускает JWT и переводит запрос в Consumed (одноразовый claim), см. api-design.md. /// Осознанное отступление от чистого CQRS ради простого поллинга без отдельного claim-эндпоинта. /// Claim — под AdvisoryLock (по Id запроса): без неё два одновременных поллинга (два открытых окна) /// оба могли бы увидеть Approved до того, как первый допишет Consume(), и оба выпустить валидную пару /// токенов из одного подтверждения. /// public sealed class GetLoginRequestStatusQueryHandler( IAppDbContext dbContext, IIdentityService identityService, IJwtTokenService jwtTokenService, IRefreshTokenService refreshTokenService ) : IQueryHandler> { public async Task> Handle( GetLoginRequestStatusQuery query, CancellationToken cancellationToken ) { var request = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync( r => r.Id == query.RequestId, cancellationToken ); if (request is null) return Result.Failure(TelegramErrors.LoginRequestNotFound); if (request.Status == TelegramLoginStatus.Pending && request.IsExpired) return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Expired, null)); if (request.Status != TelegramLoginStatus.Approved) return Result.Success(new LoginRequestStatusDto(request.Status, null)); var claimed = await AdvisoryLock.RunAsync( dbContext, query.RequestId, async lockedCancellationToken => { var fresh = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync( r => r.Id == query.RequestId, lockedCancellationToken ); if (fresh is null || fresh.Status != TelegramLoginStatus.Approved) return Result.Failure(TelegramErrors.LoginRequestAlreadyClaimed); fresh.Consume(); return Result.Success(fresh.UserId!.Value); }, cancellationToken ); if (!claimed.IsSuccess) { // Проиграли гонку — запрос уже забрал кто-то другой (обычно второй открытый poll той же // вкладки/окна). Не ошибка для клиента — отдаём актуальный статус без токенов, как при // обычном повторном поллинге уже потреблённого запроса. var current = await dbContext.TelegramLoginRequests.FirstOrDefaultAsync( r => r.Id == query.RequestId, cancellationToken ); return Result.Success( new LoginRequestStatusDto(current?.Status ?? TelegramLoginStatus.Consumed, null) ); } var profile = await identityService.GetProfileAsync(claimed.Value, cancellationToken); if (profile is null) return Result.Failure(AuthErrors.Unauthorized); // Обычный логин отказывает заблокированному сразу — passwordless-вход через Telegram обязан // делать то же самое, иначе блокировка полностью обходится этим путём. if (profile.IsBlocked) return Result.Failure(AuthErrors.UserBlocked); var authUser = new AuthenticatedUser(profile.Id, profile.UserName, profile.Role); var (accessToken, accessExpiresAt) = jwtTokenService.GenerateAccessToken(authUser); var refreshToken = await refreshTokenService.IssueAsync(profile.Id, cancellationToken); // Пользователь только что подтвердил вход через бота — Telegram точно привязан. var dto = new CurrentUserDto( profile.Id, profile.UserName, profile.Role, profile.IsActivated, TelegramLinked: true ); var auth = new AuthResult( accessToken, accessExpiresAt, refreshToken.RawToken, refreshToken.ExpiresAt, dto ); return Result.Success(new LoginRequestStatusDto(TelegramLoginStatus.Approved, auth)); } }