Files
evening_detective_server/internal/app/screens.go
T
2026-10-27 12:03:22 +07:00

395 lines
17 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"time"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/internal/services/screen_service"
proto "evening_detective_server/proto"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) CreateScreenSession(ctx context.Context, _ *proto.CreateScreenSessionReq) (*proto.CreateScreenSessionRsp, error) {
token := uuid.New().String()
id, err := s.screenService.Create(ctx, hashScreenToken(token))
if err != nil {
return nil, status.Error(codes.Internal, "не удалось создать сессию экрана")
}
return &proto.CreateScreenSessionRsp{ScreenID: fmt.Sprint(id), AccessToken: token}, nil
}
func (s *server) GetScreenState(ctx context.Context, req *proto.GetScreenStateReq) (*proto.GetScreenStateRsp, error) {
if req == nil || req.ScreenID == "" || req.AccessToken == "" {
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
session, err := s.screenService.Get(ctx, id, hashScreenToken(req.AccessToken))
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить состояние экрана")
}
state := screenState(session)
s.enrichScreenState(ctx, session, state)
return &proto.GetScreenStateRsp{State: state}, nil
}
func (s *server) AdvanceScreenMusic(ctx context.Context, req *proto.AdvanceScreenMusicReq) (*proto.AdvanceScreenMusicRsp, error) {
if req == nil || req.ScreenID == "" || req.AccessToken == "" {
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
session, err := s.screenService.Get(ctx, id, hashScreenToken(req.AccessToken))
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
if err != nil || session.GameID == nil {
return nil, status.Error(codes.FailedPrecondition, "экран не привязан к игре")
}
if !session.Sound {
return nil, status.Error(codes.FailedPrecondition, "экран не назначен для звука")
}
updated, err := s.playlistService.Advance(ctx, *session.GameID, req.GetExpectedStateVersion())
if err != nil {
return nil, playlistErrorStatus(err, "не удалось переключить трек")
}
return &proto.AdvanceScreenMusicRsp{State: mapMusicState(updated)}, nil
}
func (s *server) GetGameScreens(ctx context.Context, req *proto.GetGameScreensReq) (*proto.GetGameScreensRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор игры")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
sessions, err := s.screenService.ListByGame(ctx, int(req.GameID))
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить экраны игры")
}
states := make([]*proto.ScreenState, 0, len(sessions))
for _, session := range sessions {
state := screenState(session)
s.enrichScreenState(ctx, session, state)
states = append(states, state)
}
return &proto.GetGameScreensRsp{Screens: states}, nil
}
func (s *server) BindScreen(ctx context.Context, req *proto.BindScreenReq) (*proto.BindScreenRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.ScreenID == "" || req.AccessToken == "" || req.GameID <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректные данные привязки")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
if _, err = s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
hash := hashScreenToken(req.AccessToken)
isNewBinding, err := s.screenService.Bind(ctx, id, int(req.GameID), hash, req.GetInitializeAudio())
if err != nil {
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
return nil, status.Error(codes.Internal, "не удалось привязать экран")
}
if isNewBinding {
if err := s.screenPlanService.ApplyCurrent(ctx, int(req.GameID), id); err != nil {
return nil, status.Error(codes.Internal, "не удалось применить план экранов")
}
}
session, err := s.screenService.Get(ctx, id, hash)
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить состояние экрана")
}
state := screenState(session)
s.enrichScreenState(ctx, session, state)
return &proto.BindScreenRsp{State: state}, nil
}
func (s *server) UnbindScreen(ctx context.Context, req *proto.UnbindScreenReq) (*proto.UnbindScreenRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.ScreenID == "" {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
if err = s.screenService.Unbind(ctx, id); err != nil {
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.NotFound, "экран не найден")
}
return nil, status.Error(codes.Internal, "не удалось отвязать экран")
}
return &proto.UnbindScreenRsp{}, nil
}
func (s *server) SetScreenSound(ctx context.Context, req *proto.SetScreenSoundReq) (*proto.SetScreenSoundRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.ScreenID == "" {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
if err = s.screenService.SetSound(ctx, id, req.SoundEnabled); err != nil {
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.NotFound, "экран не найден")
}
return nil, status.Error(codes.Internal, "не удалось назначить экран со звуком")
}
return &proto.SetScreenSoundRsp{}, nil
}
func canManageScreens(ctx context.Context) bool {
claims, ok := processor_jwt.ClaimsFromContext(ctx)
if !ok || claims == nil {
return false
}
return roles.HasRole(claims, roles.Organizer) || roles.HasRole(claims, roles.Author)
}
func hashScreenToken(token string) string {
sum := sha256.Sum256([]byte(token))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func screenState(session *screen_service.ScreenSession) *proto.ScreenState {
timerState := proto.ScreenTimerState(session.TimerState)
timerRemaining := session.TimerRemainingSeconds
if timerState == proto.ScreenTimerState_SCREEN_TIMER_STATE_RUNNING {
if session.TimerEndsAt == nil || !session.TimerEndsAt.After(time.Now()) {
timerRemaining = 0
timerState = proto.ScreenTimerState_SCREEN_TIMER_STATE_FINISHED
} else {
timerRemaining = int64(time.Until(*session.TimerEndsAt) / time.Second)
}
}
state := &proto.ScreenState{
ID: fmt.Sprint(session.ID),
Status: "disconnected",
SoundEnabled: session.Sound,
Mode: proto.ScreenMode(session.Mode),
StateVersion: session.Version,
PositionMs: session.Position,
Command: proto.ScreenCommand(session.Command),
Volume: session.Volume,
TimerLabel: session.TimerLabel,
TimerDurationSeconds: session.TimerDurationSeconds,
TimerRemainingSeconds: timerRemaining,
TimerState: timerState,
Text: session.Text,
QrUrl: session.QRURL,
TimerType: proto.ScreenTimerType(session.TimerType),
}
if time.Since(session.LastSeen) <= 5*time.Second {
state.Status = "connected"
}
if session.GameID != nil {
gameID := int32(*session.GameID)
state.GameID = &gameID
if state.Mode == proto.ScreenMode_SCREEN_MODE_UNSPECIFIED {
state.Mode = proto.ScreenMode_SCREEN_MODE_COVER
}
}
return state
}
func (s *server) enrichScreenState(ctx context.Context, session *screen_service.ScreenSession, state *proto.ScreenState) {
if session.GameID == nil {
return
}
if s.playlistService != nil {
if music, err := s.playlistService.GetGame(ctx, *session.GameID); err == nil {
state.Music = mapMusicState(music)
}
}
game, err := s.gameService.GetGameMedia(ctx, *session.GameID)
if err != nil {
return
}
state.GameName = game.Name
if game.Scenario != nil && game.Scenario.Image != nil {
state.CoverImage = *game.Scenario.Image
}
if game.Scenario != nil && game.Scenario.Story != nil {
state.BackgroundImage = game.Scenario.Story.Background
if game.Scenario.Story.Introduction != nil {
state.IntroAudio = game.Scenario.Story.Introduction.Audio
}
if game.Scenario.Story.Conclusion != nil {
state.ConclusionVideo = game.Scenario.Story.Conclusion.Video
}
}
if state.BackgroundImage == "" {
state.BackgroundImage = state.CoverImage
}
}
func (s *server) SetScreenMode(ctx context.Context, req *proto.SetScreenModeReq) (*proto.SetScreenModeRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 || !screen_service.ValidMode(int32(req.Mode)) {
return nil, status.Error(codes.InvalidArgument, "некорректный режим экрана")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
if req.Mode == proto.ScreenMode_SCREEN_MODE_QR {
if !screen_service.ValidQR(req.QrUrl, req.Text) {
return nil, status.Error(codes.InvalidArgument, "Укажите ссылку HTTP/HTTPS до 2048 байт и текст до 10000 байт")
}
if err := s.screenService.SetQR(ctx, int(req.GameID), req.QrUrl, req.Text); err != nil {
return nil, status.Error(codes.Internal, "не удалось изменить QR-код")
}
return &proto.SetScreenModeRsp{}, nil
}
if err := s.screenService.SetMode(ctx, int(req.GameID), int32(req.Mode)); err != nil {
return nil, status.Error(codes.Internal, "не удалось изменить режим экранов")
}
return &proto.SetScreenModeRsp{}, nil
}
func (s *server) SetScreenTimer(ctx context.Context, req *proto.SetScreenTimerReq) (*proto.SetScreenTimerRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 || req.DurationSeconds <= 0 || req.DurationSeconds > 24*60*60 || !screen_service.ValidTimerType(int32(req.TimerType)) {
return nil, status.Error(codes.InvalidArgument, "некорректные параметры таймера")
}
label := strings.TrimSpace(req.Label)
if label == "" {
label = screen_service.TimerLabel(int32(req.TimerType))
}
if len(label) > 200 {
return nil, status.Error(codes.InvalidArgument, "слишком длинная надпись таймера")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
if err := s.screenService.SetTimer(ctx, int(req.GameID), req.DurationSeconds, label, int32(req.TimerType)); err != nil {
return nil, status.Error(codes.Internal, "не удалось настроить таймер экранов")
}
return &proto.SetScreenTimerRsp{}, nil
}
func (s *server) SendScreenCommand(ctx context.Context, req *proto.SendScreenCommandReq) (*proto.SendScreenCommandRsp, error) {
if !canManageScreens(ctx) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameID <= 0 || req.Command == proto.ScreenCommand_SCREEN_COMMAND_UNSPECIFIED {
return nil, status.Error(codes.InvalidArgument, "некорректная команда экрана")
}
if _, err := s.gameService.GetGame(ctx, int(req.GameID)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
if isTimerCommand(req.Command) {
if err := s.screenService.SetTimerCommand(ctx, int(req.GameID), int32(req.Command)); err != nil {
return nil, status.Error(codes.Internal, "не удалось выполнить команду таймера")
}
return &proto.SendScreenCommandRsp{}, nil
}
if req.PositionMs < 0 {
return nil, status.Error(codes.InvalidArgument, "некорректная позиция")
}
if req.Volume < 0 || req.Volume > 100 {
return nil, status.Error(codes.InvalidArgument, "некорректная громкость")
}
if err := s.screenService.SetCommand(ctx, int(req.GameID), int32(req.Command), req.PositionMs, req.Volume); err != nil {
return nil, status.Error(codes.Internal, "не удалось сохранить позицию экранов")
}
return &proto.SendScreenCommandRsp{}, nil
}
func isTimerCommand(command proto.ScreenCommand) bool {
return command >= proto.ScreenCommand_SCREEN_COMMAND_TIMER_START && command <= proto.ScreenCommand_SCREEN_COMMAND_TIMER_RESET
}
func (s *server) GetScreenResults(ctx context.Context, req *proto.GetScreenResultsReq) (*proto.GetScreenResultsRsp, error) {
if req == nil || req.ScreenID == "" || req.AccessToken == "" {
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
session, err := s.screenService.Get(ctx, id, hashScreenToken(req.AccessToken))
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
if err != nil || session.GameID == nil {
return nil, status.Error(codes.FailedPrecondition, "экран не привязан к игре")
}
game, err := s.gameService.GetGame(ctx, *session.GameID)
if err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена")
}
if game.Status != "finish" {
return nil, status.Error(codes.FailedPrecondition, "результаты доступны после завершения игры")
}
results, err := s.answerService.GetGameResults(ctx, *session.GameID)
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить результаты")
}
return &proto.GetScreenResultsRsp{Questions: mapResultsQuestions(results.Questions), Teams: mapTeamResults(results.Teams)}, nil
}
func (s *server) GetScreenTeams(ctx context.Context, req *proto.GetScreenTeamsReq) (*proto.GetScreenTeamsRsp, error) {
if req == nil || req.ScreenID == "" || req.AccessToken == "" {
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
}
id, err := strconv.Atoi(req.ScreenID)
if err != nil || id <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор экрана")
}
session, err := s.screenService.Get(ctx, id, hashScreenToken(req.AccessToken))
if errors.Is(err, screen_service.ErrScreenNotFound) {
return nil, status.Error(codes.Unauthenticated, "недействительная сессия экрана")
}
if err != nil || session.GameID == nil {
return nil, status.Error(codes.FailedPrecondition, "экран не привязан к игре")
}
teams, err := s.teamService.GetScreenTeams(ctx, *session.GameID)
if err != nil {
return nil, status.Error(codes.Internal, "не удалось получить команды")
}
result := make([]*proto.ScreenTeam, 0, len(teams))
for _, team := range teams {
result = append(result, &proto.ScreenTeam{ID: team.ID, Name: team.Name})
}
return &proto.GetScreenTeamsRsp{Teams: result}, nil
}