diff --git a/internal/app/screens.go b/internal/app/screens.go index bdf631c..759c111 100644 --- a/internal/app/screens.go +++ b/internal/app/screens.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "strconv" + "strings" "time" "evening_detective_server/internal/modules/processor_jwt" @@ -153,15 +154,29 @@ func hashScreenToken(token string) string { } func screenState(session *screen_sessions_repo.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, + 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, } if time.Since(session.LastSeen) <= 5*time.Second { state.Status = "connected" @@ -218,8 +233,27 @@ func (s *server) SetScreenMode(ctx context.Context, req *proto.SetScreenModeReq) return &proto.SetScreenModeRsp{}, nil } -func (s *server) SetScreenTimer(context.Context, *proto.SetScreenTimerReq) (*proto.SetScreenTimerRsp, error) { - return nil, status.Error(codes.Unimplemented, "таймер экранов ещё не реализован") +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 { + return nil, status.Error(codes.InvalidArgument, "некорректные параметры таймера") + } + label := strings.TrimSpace(req.Label) + if label == "" { + label = "Перерыв" + } + 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.screenRepo.SetTimer(ctx, int(req.GameId), req.DurationSeconds, label); 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) { @@ -232,6 +266,12 @@ func (s *server) SendScreenCommand(ctx context.Context, req *proto.SendScreenCom if _, err := s.gameService.GetGame(ctx, int(req.GameId)); err != nil { return nil, status.Error(codes.NotFound, "игра не найдена") } + if isTimerCommand(req.Command) { + if err := s.screenRepo.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, "некорректная позиция") } @@ -244,6 +284,10 @@ func (s *server) SendScreenCommand(ctx context.Context, req *proto.SendScreenCom 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, "некорректные данные экрана") diff --git a/internal/app/screens_timer_test.go b/internal/app/screens_timer_test.go new file mode 100644 index 0000000..8b3f722 --- /dev/null +++ b/internal/app/screens_timer_test.go @@ -0,0 +1,33 @@ +package app + +import ( + "testing" + "time" + + "evening_detective_server/internal/repos/screen_sessions_repo" + proto "evening_detective_server/proto" +) + +func TestScreenStateTimer(t *testing.T) { + session := &screen_sessions_repo.ScreenSession{ + ID: 1, + LastSeen: time.Now(), + TimerLabel: "Перерыв", + TimerDurationSeconds: 600, + TimerRemainingSeconds: 600, + TimerState: int32(proto.ScreenTimerState_SCREEN_TIMER_STATE_RUNNING), + } + + endsAt := time.Now().Add(10 * time.Second) + session.TimerEndsAt = &endsAt + state := screenState(session) + if state.TimerState != proto.ScreenTimerState_SCREEN_TIMER_STATE_RUNNING || state.TimerRemainingSeconds <= 0 || state.TimerRemainingSeconds > 10 { + t.Fatalf("unexpected running timer: state=%s remaining=%d", state.TimerState, state.TimerRemainingSeconds) + } + + endsAt = time.Now().Add(-time.Second) + state = screenState(session) + if state.TimerState != proto.ScreenTimerState_SCREEN_TIMER_STATE_FINISHED || state.TimerRemainingSeconds != 0 { + t.Fatalf("unexpected finished timer: state=%s remaining=%d", state.TimerState, state.TimerRemainingSeconds) + } +} diff --git a/internal/repos/screen_sessions_repo/repo.go b/internal/repos/screen_sessions_repo/repo.go index 05ee062..2180777 100644 --- a/internal/repos/screen_sessions_repo/repo.go +++ b/internal/repos/screen_sessions_repo/repo.go @@ -12,15 +12,20 @@ import ( var ErrScreenNotFound = errors.New("Экран не найден") type ScreenSession struct { - ID int - GameID *int - Sound bool - LastSeen time.Time - Mode int32 - Version int64 - Position int64 - Command int32 - Volume int32 + ID int + GameID *int + Sound bool + LastSeen time.Time + Mode int32 + Version int64 + Position int64 + Command int32 + Volume int32 + TimerLabel string + TimerDurationSeconds int64 + TimerRemainingSeconds int64 + TimerState int32 + TimerEndsAt *time.Time } type ScreenSessionsRepo struct { @@ -43,10 +48,12 @@ func (r *ScreenSessionsRepo) Create(ctx context.Context, tokenHash string) (int, func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string) (*ScreenSession, error) { session := &ScreenSession{ID: id} err := r.pool.QueryRow(ctx, ` - SELECT game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume + SELECT game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume, + timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at FROM screen_sessions WHERE id = $1 AND access_token_hash = $2`, id, tokenHash). - Scan(&session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume) + Scan(&session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume, + &session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt) if errors.Is(err, pgx.ErrNoRows) { return nil, ErrScreenNotFound } @@ -63,7 +70,8 @@ func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string) func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*ScreenSession, error) { rows, err := r.pool.Query(ctx, ` - SELECT id, game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume + SELECT id, game_id, sound_enabled, last_seen_at, mode, state_version, position_ms, command, volume, + timer_label, timer_duration_seconds, timer_remaining_seconds, timer_state, timer_ends_at FROM screen_sessions WHERE game_id = $1 ORDER BY id`, gameID) @@ -75,7 +83,8 @@ func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*Scr var sessions []*ScreenSession for rows.Next() { session := &ScreenSession{} - if err := rows.Scan(&session.ID, &session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume); err != nil { + if err := rows.Scan(&session.ID, &session.GameID, &session.Sound, &session.LastSeen, &session.Mode, &session.Version, &session.Position, &session.Command, &session.Volume, + &session.TimerLabel, &session.TimerDurationSeconds, &session.TimerRemainingSeconds, &session.TimerState, &session.TimerEndsAt); err != nil { return nil, err } sessions = append(sessions, session) @@ -98,6 +107,52 @@ func (r *ScreenSessionsRepo) SetCommand(ctx context.Context, gameID int, command return err } +func (r *ScreenSessionsRepo) SetTimer(ctx context.Context, gameID int, duration int64, label string) error { + _, err := r.pool.Exec(ctx, ` + UPDATE screen_sessions + SET mode = 7, + timer_label = $2, + timer_duration_seconds = $3, + timer_remaining_seconds = $3, + timer_state = 1, + timer_ends_at = NULL, + command = 0, + state_version = state_version + 1, + updated_at = NOW() + WHERE game_id = $1`, gameID, label, duration) + return err +} + +func (r *ScreenSessionsRepo) SetTimerCommand(ctx context.Context, gameID int, command int32) error { + _, err := r.pool.Exec(ctx, ` + UPDATE screen_sessions + SET timer_remaining_seconds = CASE + WHEN $2 = 8 THEN timer_duration_seconds + WHEN $2 = 9 AND timer_ends_at IS NOT NULL THEN GREATEST(0, FLOOR(EXTRACT(EPOCH FROM (timer_ends_at - NOW())))::BIGINT) + WHEN $2 = 11 THEN timer_duration_seconds + ELSE timer_remaining_seconds + END, + timer_state = CASE + WHEN $2 = 8 THEN 2 + WHEN $2 = 9 THEN CASE WHEN timer_ends_at IS NULL OR timer_ends_at > NOW() THEN 3 ELSE 4 END + WHEN $2 = 10 THEN CASE WHEN timer_remaining_seconds > 0 THEN 2 ELSE 4 END + WHEN $2 = 11 THEN 1 + ELSE timer_state + END, + timer_ends_at = CASE + WHEN $2 = 8 THEN NOW() + make_interval(secs => timer_duration_seconds) + WHEN $2 = 9 OR $2 = 11 THEN NULL + WHEN $2 = 10 AND timer_remaining_seconds > 0 THEN NOW() + make_interval(secs => timer_remaining_seconds) + ELSE timer_ends_at + END, + mode = 7, + command = $2, + state_version = state_version + 1, + updated_at = NOW() + WHERE game_id = $1`, gameID, command) + return err +} + func (r *ScreenSessionsRepo) Bind(ctx context.Context, id, gameID int, tokenHash string) error { result, err := r.pool.Exec(ctx, ` UPDATE screen_sessions @@ -115,7 +170,17 @@ func (r *ScreenSessionsRepo) Bind(ctx context.Context, id, gameID int, tokenHash func (r *ScreenSessionsRepo) Unbind(ctx context.Context, id int) error { result, err := r.pool.Exec(ctx, ` UPDATE screen_sessions - SET game_id = NULL, mode = 1, command = 0, position_ms = 0, state_version = state_version + 1, updated_at = NOW() + SET game_id = NULL, + mode = 1, + command = 0, + position_ms = 0, + timer_label = 'Перерыв', + timer_duration_seconds = 600, + timer_remaining_seconds = 600, + timer_state = 1, + timer_ends_at = NULL, + state_version = state_version + 1, + updated_at = NOW() WHERE id = $1`, id) if err != nil { return err diff --git a/migrations/20260921120000_add_screen_timer.sql b/migrations/20260921120000_add_screen_timer.sql new file mode 100644 index 0000000..632da30 --- /dev/null +++ b/migrations/20260921120000_add_screen_timer.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE screen_sessions + ADD COLUMN IF NOT EXISTS timer_label TEXT NOT NULL DEFAULT 'Перерыв', + ADD COLUMN IF NOT EXISTS timer_duration_seconds BIGINT NOT NULL DEFAULT 600, + ADD COLUMN IF NOT EXISTS timer_remaining_seconds BIGINT NOT NULL DEFAULT 600, + ADD COLUMN IF NOT EXISTS timer_state INTEGER NOT NULL DEFAULT 1, + ADD COLUMN IF NOT EXISTS timer_ends_at TIMESTAMP; + +-- +goose Down +ALTER TABLE screen_sessions + DROP COLUMN IF EXISTS timer_ends_at, + DROP COLUMN IF EXISTS timer_state, + DROP COLUMN IF EXISTS timer_remaining_seconds, + DROP COLUMN IF EXISTS timer_duration_seconds, + DROP COLUMN IF EXISTS timer_label;