using Microsoft.Extensions.Logging; using SameBeat.Application.DTO; using SameBeat.Application.Interfejsy; using SameBeat.Application.Komendy; using SameBeat.Domain.Encje; using SameBeat.Domain.Enumy; namespace SameBeat.Application.Serwisy; /// /// Logika sceny glownej (ETAP 3): uklad/sloty sterowane przez hosta oraz bezpieczne "wpuszczenie na /// scene" uczestnika, ktory udowadnia tozsamosc WYLACZNIE sekretem sesji (nigdy samym id). /// public sealed class SerwisSceny : ISerwisSceny { private readonly IRepozytoriumWydarzen _repozytoriumWydarzen; private readonly IRepozytoriumPoczekalni _repozytoriumPoczekalni; private readonly IRepozytoriumSceny _repozytoriumSceny; private readonly ISerwisTokenowLiveKit _serwisTokenowLiveKit; private readonly ISerwisAdministracjiLiveKit _serwisAdministracjiLiveKit; private readonly IUslugaHaszowaniaSekretowSesji _uslugaHaszowaniaSekretowSesji; private readonly IJednostkaPracy _jednostkaPracy; private readonly string _urlLiveKit; private readonly string _nazwaPokojuGlowna; private readonly ILogger _logger; public SerwisSceny( IRepozytoriumWydarzen repozytoriumWydarzen, IRepozytoriumPoczekalni repozytoriumPoczekalni, IRepozytoriumSceny repozytoriumSceny, ISerwisTokenowLiveKit serwisTokenowLiveKit, ISerwisAdministracjiLiveKit serwisAdministracjiLiveKit, IUslugaHaszowaniaSekretowSesji uslugaHaszowaniaSekretowSesji, IJednostkaPracy jednostkaPracy, string urlLiveKit, string nazwaPokojuGlowna, ILogger logger) { _repozytoriumWydarzen = repozytoriumWydarzen; _repozytoriumPoczekalni = repozytoriumPoczekalni; _repozytoriumSceny = repozytoriumSceny; _serwisTokenowLiveKit = serwisTokenowLiveKit; _serwisAdministracjiLiveKit = serwisAdministracjiLiveKit; _uslugaHaszowaniaSekretowSesji = uslugaHaszowaniaSekretowSesji; _jednostkaPracy = jednostkaPracy; _urlLiveKit = urlLiveKit; _nazwaPokojuGlowna = nazwaPokojuGlowna; _logger = logger; } public async Task> PobierzStanScenyAsync(CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { // Brak wydarzenia to normalny stan (host jeszcze niczego nie utworzyl) -- pusta scena, nie blad. return WynikOperacji.Ok(new StanScenyDto(UkladSceny.Grid4, new List())); } var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token); return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty)); } public async Task> ZmienUkladAsync(UkladSceny nowyUklad, CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { return WynikOperacji.Niepowodzenie("Brak aktywnego wydarzenia, sprobuj pozniej."); } wydarzenie.ZmienUklad(nowyUklad); var liczbaSlotow = RejestrSlotowSceny.PobierzLiczbeSlotow(nowyUklad); var zajeteSloty = await _repozytoriumSceny.PobierzZajeteDlaWydarzeniaAsync(wydarzenie.Id, token); var uczestnicyUsunieciZeSceny = new List(); foreach (var slot in zajeteSloty.Where(s => s.NumerSlotu > liczbaSlotow)) { // Sierocy slot po zmniejszeniu ukladu -- zwalniamy go, a jego uczestnik wraca do poczekalni. var uczestnikUsuniety = await ZwolnijSlotWewnetrznieAsync(slot, token); if (uczestnikUsuniety is not null) { uczestnicyUsunieciZeSceny.Add(uczestnikUsuniety); } } await _jednostkaPracy.ZapiszZmianyAsync(token); _logger.LogInformation("Uklad sceny wydarzenia {WydarzenieId} zmieniony na {Uklad}.", wydarzenie.Id, nowyUklad); // Best effort, PO zapisie -- ETAP 7: to samo twarde wymuszenie co przy pojedynczym ZwolnijSlotAsync // (patrz komentarz tam) -- sierocy slot po zmniejszeniu ukladu to dokladnie ten sam przypadek // "uczestnik przestal byc na scenie", wiec musi tak samo fizycznie stracic canPublishSources. foreach (var uczestnikUsuniety in uczestnicyUsunieciZeSceny) { await _serwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync(uczestnikUsuniety.LiveKitIdentity, _nazwaPokojuGlowna, token); } var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token); return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty)); } public async Task> PrzypiszSlotAsync(int numerSlotu, Guid uczestnikId, CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { return WynikOperacji.Niepowodzenie("Brak aktywnego wydarzenia, sprobuj pozniej."); } if (!RejestrSlotowSceny.CzyNumerSlotuJestPoprawny(wydarzenie.UkladSceny, numerSlotu)) { var maksSlotow = RejestrSlotowSceny.PobierzLiczbeSlotow(wydarzenie.UkladSceny); return WynikOperacji.Niepowodzenie( $"Numer slotu {numerSlotu} jest poza zakresem ukladu {wydarzenie.UkladSceny} (dostepne 1..{maksSlotow})."); } var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token); if (uczestnik is null) { return WynikOperacji.Niepowodzenie("Nie znaleziono uczestnika o podanym identyfikatorze."); } if (uczestnik.WydarzenieId != wydarzenie.Id) { return WynikOperacji.Niepowodzenie("Uczestnik nie nalezy do biezacego wydarzenia."); } var istniejacySlot = await _repozytoriumSceny.ZnajdzPoNumerzeAsync(wydarzenie.Id, numerSlotu, token); if (istniejacySlot is not null && istniejacySlot.UczestnikOczekujacyId == uczestnikId) { // No-op: uczestnik juz zajmuje ten slot -- nic do zrobienia, ale to sukces, nie blad. var slotyNoOp = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token); return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, slotyNoOp)); } // Poza no-op wyzej, przypisac mozna tylko uczestnika, ktory realnie oczekuje w poczekalni -- // to jednoczesnie chroni przed "przeciaganiem" kogos juz-na-scenie z innego slotu (musialby // najpierw zostac zdjety) i przed przypisaniem odrzuconego/zbanowanego. if (uczestnik.Status != StatusUczestnikaOczekujacego.Oczekuje) { return WynikOperacji.Niepowodzenie( $"Mozna wpuscic na scene tylko uczestnika ze statusem Oczekuje (aktualny status: {uczestnik.Status})."); } UczestnikOczekujacy? poprzedniUczestnik = null; if (istniejacySlot is not null && istniejacySlot.UczestnikOczekujacyId is Guid poprzedniUczestnikId) { poprzedniUczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(poprzedniUczestnikId, token); poprzedniUczestnik?.ZdejmijZeSceny(); istniejacySlot.Zwolnij(); } var slot = istniejacySlot ?? SlotSceny.Utworz(wydarzenie.Id, numerSlotu); if (istniejacySlot is null) { _repozytoriumSceny.Dodaj(slot); } slot.Przypisz(uczestnikId); uczestnik.WpuscNaScene(); await _jednostkaPracy.ZapiszZmianyAsync(token); _logger.LogInformation("Uczestnik {UczestnikId} przypisany do slotu {NumerSlotu} wydarzenia {WydarzenieId}.", uczestnikId, numerSlotu, wydarzenie.Id); // ETAP 7: gdy slot byl juz zajety, "przeciagniecie" go na nowego uczestnika to dla poprzedniego // dokladnie taki sam przypadek jak ZwolnijSlotAsync -- musi wiec tak samo twardo stracic // canPublishSources w LiveKit, best effort, PO zapisie. if (poprzedniUczestnik is not null) { await _serwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync(poprzedniUczestnik.LiveKitIdentity, _nazwaPokojuGlowna, token); } var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token); return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty)); } public async Task> ZwolnijSlotAsync(int numerSlotu, CancellationToken token = default) { var wydarzenie = await _repozytoriumWydarzen.PobierzOstatnieAsync(token); if (wydarzenie is null) { return WynikOperacji.Niepowodzenie("Brak aktywnego wydarzenia, sprobuj pozniej."); } var slot = await _repozytoriumSceny.ZnajdzPoNumerzeAsync(wydarzenie.Id, numerSlotu, token); // Zwolnienie pustego/nieistniejacego slotu jest idempotentne -- to sukces, nie blad: host // mogl kliknac "usun ze sceny" dwa razy albo klient mial nieaktualny stan. // // ETAP 7: oprocz "miekkiego" usuniecia w bazie (ponizej), TWARDO wymuszamy je tez w LiveKit -- // best effort, PO zapisie, zeby uczestnik fizycznie stracil canPublishSources (kamera+mikrofon) // niezaleznie od tego, czy klient w ogole dostanie/obsluzy zdarzenie SignalR ponizej. Wczesniej // (ETAPY 2-3) to bylo swiadomie odlozone -- klient MIAL OBOWIAZEK sam przestac publikowac. if (slot is not null && slot.UczestnikOczekujacyId is Guid uczestnikId) { var uczestnikUsuniety = await ZwolnijSlotWewnetrznieAsync(slot, token); await _jednostkaPracy.ZapiszZmianyAsync(token); _logger.LogInformation("Slot {NumerSlotu} wydarzenia {WydarzenieId} zwolniony (uczestnik {UczestnikId} wraca do poczekalni).", numerSlotu, wydarzenie.Id, uczestnikId); if (uczestnikUsuniety is not null) { await _serwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync(uczestnikUsuniety.LiveKitIdentity, _nazwaPokojuGlowna, token); } } var sloty = await _repozytoriumSceny.PobierzZajeteZNickamiAsync(wydarzenie.Id, token); return WynikOperacji.Ok(new StanScenyDto(wydarzenie.UkladSceny, sloty)); } public async Task> PobierzStatusUczestnikaAsync(Guid uczestnikId, string sekret, CancellationToken token = default) { var (uczestnik, blad) = await ZweryfikujSekretAsync(uczestnikId, sekret, token); if (uczestnik is null) { return WynikOperacji.Niepowodzenie(blad!); } // Wydarzenie trzyma ZYWY stan mikrofonu (AktywnyMikrofonUczestnikId) -- porownujemy z tym // konkretnym uczestnikiem, zeby powiedziec mu (i TYLKO jemu, przez ten uwierzytelniony kanal) // "to ty masz teraz glos", bez ujawniania tego publicznie. var wydarzenie = await _repozytoriumWydarzen.ZnajdzPoIdAsync(uczestnik.WydarzenieId, token); var mikrofonAktywny = wydarzenie is not null && wydarzenie.AktywnyMikrofonUczestnikId == uczestnik.Id; return WynikOperacji.Ok(new StatusUczestnikaDto( uczestnik.Status, mikrofonAktywny, mikrofonAktywny ? wydarzenie!.AktywnyMikrofonKoniecUtc : null)); } public async Task> WydajTokenScenyAsync(TokenScenyKomenda komenda, CancellationToken token = default) { var (uczestnik, blad) = await ZweryfikujSekretAsync(komenda.UczestnikId, komenda.Sekret, token); if (uczestnik is null) { return WynikOperacji.Niepowodzenie(blad!); } if (uczestnik.Status != StatusUczestnikaOczekujacego.NaScenie) { _logger.LogInformation("Odmowa tokenu sceny -- uczestnik {UczestnikId} nie jest jeszcze wpuszczony na scene (status: {Status}).", uczestnik.Id, uczestnik.Status); return WynikOperacji.Niepowodzenie("Nie zostales jeszcze wpuszczony na scene przez hosta."); } if (string.IsNullOrWhiteSpace(_urlLiveKit)) { _logger.LogWarning("Token sceny odrzucony -- adres serwera LiveKit nie jest skonfigurowany."); return WynikOperacji.Niepowodzenie("Usluga LiveKit nie jest jeszcze skonfigurowana na serwerze."); } // Token do pokoju GLOWNEGO (nie poczekalni!) -- to jest wlasnie moment "przejscia" z poczekalni // na scene widoczna dla widzow. Rola Uczestnik: canPublish=true, ale wylacznie kamera -- tak // samo jak w poczekalni, mikrofon nadal nigdy nie jest domyslnie wlaczony (dopiero ETAP 5). var liveKitToken = _serwisTokenowLiveKit.WygenerujToken(uczestnik.LiveKitIdentity, uczestnik.Nick, RolaUzytkownika.Uczestnik, _nazwaPokojuGlowna); _logger.LogInformation("Wydano token sceny glownej dla uczestnika {UczestnikId}.", uczestnik.Id); return WynikOperacji.Ok(new OdpowiedzTokenuLiveKitDto(liveKitToken, _urlLiveKit)); } /// /// Wspolna weryfikacja "czy ten, kto twierdzi ze jest uczestnikiem X, zna jego sekret sesji" -- /// uzywana przez oba publiczne endpointy uczestnika. Zly sekret i nieznany uczestnik dostaja /// CELOWO ten sam komunikat, zeby nie ujawniac, czy dany identyfikator w ogole istnieje. /// private async Task<(UczestnikOczekujacy? Uczestnik, string? Blad)> ZweryfikujSekretAsync(Guid uczestnikId, string sekret, CancellationToken token) { const string bladNieprawidloweDane = "Nieprawidlowy identyfikator uczestnika lub sekret sesji."; if (string.IsNullOrWhiteSpace(sekret)) { return (null, bladNieprawidloweDane); } var uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token); if (uczestnik is null || uczestnik.SekretSesjiHash is null) { _logger.LogInformation("Weryfikacja sekretu sesji nieudana -- nieznany uczestnik {UczestnikId}.", uczestnikId); return (null, bladNieprawidloweDane); } var wynikWeryfikacji = _uslugaHaszowaniaSekretowSesji.Weryfikuj(uczestnik, uczestnik.SekretSesjiHash, sekret); if (wynikWeryfikacji == WynikWeryfikacjiHasla.Niepoprawne) { _logger.LogInformation("Weryfikacja sekretu sesji nieudana -- bledny sekret dla uczestnika {UczestnikId}.", uczestnikId); return (null, bladNieprawidloweDane); } if (wynikWeryfikacji == WynikWeryfikacjiHasla.PoprawneWymagaRehaszowania) { // Tak samo jak przy hasle hosta (SerwisLogowania) -- przeliczamy hash "przy okazji" udanej // weryfikacji, zeby nie wymuszac ponownego dolaczenia na uczestniku. uczestnik.UstawSekretSesji(_uslugaHaszowaniaSekretowSesji.Haszuj(uczestnik, sekret)); await _jednostkaPracy.ZapiszZmianyAsync(token); } return (uczestnik, null); } /// /// Zwalnia slot w bazie i zwraca zdjetego uczestnika (albo null, gdy slot juz byl pusty) -- caller /// wola nim TWARDE wymuszenie w LiveKit (ISerwisAdministracjiLiveKit.OdbierzUprawnieniaScenyAsync), /// ale dopiero PO _jednostkaPracy.ZapiszZmianyAsync (tak samo jak SerwisMikrofonu: baza jest /// autorytatywnym zrodlem prawdy, LiveKit to "przy okazji" best effort). /// private async Task ZwolnijSlotWewnetrznieAsync(SlotSceny slot, CancellationToken token) { UczestnikOczekujacy? uczestnik = null; if (slot.UczestnikOczekujacyId is Guid uczestnikId) { uczestnik = await _repozytoriumPoczekalni.ZnajdzPoIdAsync(uczestnikId, token); uczestnik?.ZdejmijZeSceny(); } slot.Zwolnij(); return uczestnik; } }