Добавить серверную логику таймера экранов

This commit is contained in:
2026-09-21 18:17:15 +07:00
parent 458bf320d4
commit 002c4b26b4
4 changed files with 181 additions and 24 deletions
+54 -10
View File
@@ -7,6 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"time" "time"
"evening_detective_server/internal/modules/processor_jwt" "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 { 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{ state := &proto.ScreenState{
Id: fmt.Sprint(session.ID), Id: fmt.Sprint(session.ID),
Status: "disconnected", Status: "disconnected",
SoundEnabled: session.Sound, SoundEnabled: session.Sound,
Mode: proto.ScreenMode(session.Mode), Mode: proto.ScreenMode(session.Mode),
StateVersion: session.Version, StateVersion: session.Version,
PositionMs: session.Position, PositionMs: session.Position,
Command: proto.ScreenCommand(session.Command), Command: proto.ScreenCommand(session.Command),
Volume: session.Volume, Volume: session.Volume,
TimerLabel: session.TimerLabel,
TimerDurationSeconds: session.TimerDurationSeconds,
TimerRemainingSeconds: timerRemaining,
TimerState: timerState,
} }
if time.Since(session.LastSeen) <= 5*time.Second { if time.Since(session.LastSeen) <= 5*time.Second {
state.Status = "connected" state.Status = "connected"
@@ -218,8 +233,27 @@ func (s *server) SetScreenMode(ctx context.Context, req *proto.SetScreenModeReq)
return &proto.SetScreenModeRsp{}, nil return &proto.SetScreenModeRsp{}, nil
} }
func (s *server) SetScreenTimer(context.Context, *proto.SetScreenTimerReq) (*proto.SetScreenTimerRsp, error) { func (s *server) SetScreenTimer(ctx context.Context, req *proto.SetScreenTimerReq) (*proto.SetScreenTimerRsp, error) {
return nil, status.Error(codes.Unimplemented, "таймер экранов ещё не реализован") 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) { 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 { if _, err := s.gameService.GetGame(ctx, int(req.GameId)); err != nil {
return nil, status.Error(codes.NotFound, "игра не найдена") 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 { if req.PositionMs < 0 {
return nil, status.Error(codes.InvalidArgument, "некорректная позиция") return nil, status.Error(codes.InvalidArgument, "некорректная позиция")
} }
@@ -244,6 +284,10 @@ func (s *server) SendScreenCommand(ctx context.Context, req *proto.SendScreenCom
return &proto.SendScreenCommandRsp{}, nil 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) { func (s *server) GetScreenResults(ctx context.Context, req *proto.GetScreenResultsReq) (*proto.GetScreenResultsRsp, error) {
if req == nil || req.ScreenId == "" || req.AccessToken == "" { if req == nil || req.ScreenId == "" || req.AccessToken == "" {
return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана") return nil, status.Error(codes.InvalidArgument, "некорректные данные экрана")
+33
View File
@@ -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)
}
}
+79 -14
View File
@@ -12,15 +12,20 @@ import (
var ErrScreenNotFound = errors.New("Экран не найден") var ErrScreenNotFound = errors.New("Экран не найден")
type ScreenSession struct { type ScreenSession struct {
ID int ID int
GameID *int GameID *int
Sound bool Sound bool
LastSeen time.Time LastSeen time.Time
Mode int32 Mode int32
Version int64 Version int64
Position int64 Position int64
Command int32 Command int32
Volume int32 Volume int32
TimerLabel string
TimerDurationSeconds int64
TimerRemainingSeconds int64
TimerState int32
TimerEndsAt *time.Time
} }
type ScreenSessionsRepo struct { 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) { func (r *ScreenSessionsRepo) Get(ctx context.Context, id int, tokenHash string) (*ScreenSession, error) {
session := &ScreenSession{ID: id} session := &ScreenSession{ID: id}
err := r.pool.QueryRow(ctx, ` 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 FROM screen_sessions
WHERE id = $1 AND access_token_hash = $2`, id, tokenHash). 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) { if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrScreenNotFound 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) { func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*ScreenSession, error) {
rows, err := r.pool.Query(ctx, ` 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 FROM screen_sessions
WHERE game_id = $1 WHERE game_id = $1
ORDER BY id`, gameID) ORDER BY id`, gameID)
@@ -75,7 +83,8 @@ func (r *ScreenSessionsRepo) ListByGame(ctx context.Context, gameID int) ([]*Scr
var sessions []*ScreenSession var sessions []*ScreenSession
for rows.Next() { for rows.Next() {
session := &ScreenSession{} 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 return nil, err
} }
sessions = append(sessions, session) sessions = append(sessions, session)
@@ -98,6 +107,52 @@ func (r *ScreenSessionsRepo) SetCommand(ctx context.Context, gameID int, command
return err 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 { func (r *ScreenSessionsRepo) Bind(ctx context.Context, id, gameID int, tokenHash string) error {
result, err := r.pool.Exec(ctx, ` result, err := r.pool.Exec(ctx, `
UPDATE screen_sessions 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 { func (r *ScreenSessionsRepo) Unbind(ctx context.Context, id int) error {
result, err := r.pool.Exec(ctx, ` result, err := r.pool.Exec(ctx, `
UPDATE screen_sessions 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) WHERE id = $1`, id)
if err != nil { if err != nil {
return err return err
@@ -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;