This commit is contained in:
2026-10-03 13:00:50 +07:00
parent dd55079e06
commit 7a7808b4d1
28 changed files with 191 additions and 11 deletions
@@ -13,6 +13,7 @@ import (
"evening_detective_server/internal/services/scenarios_service"
)
// AnswerService handles game answers.
type AnswerService struct {
gamesRepo *games_repo.GamesRepo
teamsRepo *teams_repo.TeamsRepo
@@ -21,6 +22,7 @@ type AnswerService struct {
actionsRepo *actions_repo.ActionsRepo
}
// NewAnswerService creates an answer service.
func NewAnswerService(
gamesRepo *games_repo.GamesRepo,
teamsRepo *teams_repo.TeamsRepo,
@@ -41,11 +41,13 @@ var allowedExtensions = map[string]struct{}{
".webm": {},
}
// FileService handles file downloads.
type FileService struct {
fileStorage file_storage.IFileStorage
maxFileSize int
}
// NewFileService creates a file service.
func NewFileService(
fileStorage file_storage.IFileStorage,
maxFileSize int,
@@ -149,6 +151,7 @@ func contentTypeCategory(sniffed string) string {
}
}
// DownloadFile returns a file for download.
func (s *FileService) DownloadFile(
ctx context.Context,
filename string,
+1
View File
@@ -6,6 +6,7 @@ import (
"time"
)
// Game describes a game exposed by the service layer.
type Game struct {
Format string
ID int
@@ -5,6 +5,7 @@ import (
"evening_detective_server/internal/services/scenarios_service"
)
// MapGame converts a repository game to a service model.
func MapGame(
o *repos.Game,
domain string,
@@ -24,6 +25,7 @@ func MapGame(
}
}
// MapGames converts repository games to service models.
func MapGames(
o []*repos.Game,
domain string,
+1
View File
@@ -2,6 +2,7 @@ package game_model
import "evening_detective_server/internal/modules/storytelling"
// Team describes a team exposed by the service layer.
type Team struct {
ParticipationStatus string
ParticipationSource string
@@ -2,6 +2,7 @@ package game_model
import "evening_detective_server/internal/repos"
// MapTeam converts a repository team to a service model.
func MapTeam(
o *repos.Team,
) *Team {
@@ -19,6 +20,7 @@ func MapTeam(
}
}
// MapTeams converts repository teams to service models.
func MapTeams(
o []*repos.Team,
) []*Team {
+14
View File
@@ -13,6 +13,7 @@ import (
"time"
)
// GameService handles game lifecycle operations.
type GameService struct {
gamesRepo *games_repo.GamesRepo
teamsRepo *teams_repo.TeamsRepo
@@ -30,6 +31,7 @@ var (
ErrInvalidGameFormat = errors.New("Формат игры должен быть online или offline")
)
// NewGameService creates a game service.
func NewGameService(
gamesRepo *games_repo.GamesRepo,
teamsRepo *teams_repo.TeamsRepo,
@@ -50,6 +52,7 @@ func NewGameService(
}
}
// AddGame creates a game.
func (s *GameService) AddGame(
ctx context.Context,
name string,
@@ -72,6 +75,7 @@ func (s *GameService) AddGame(
return id, nil
}
// GetGame returns a game by ID.
func (s *GameService) GetGame(ctx context.Context, id int) (*game_model.Game, error) {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
@@ -81,6 +85,7 @@ func (s *GameService) GetGame(ctx context.Context, id int) (*game_model.Game, er
return res, nil
}
// GetGameMedia returns a game with its media references.
func (s *GameService) GetGameMedia(ctx context.Context, id int) (*game_model.Game, error) {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {
@@ -94,6 +99,7 @@ func (s *GameService) GetGameMedia(ctx context.Context, id int) (*game_model.Gam
return gameResult, nil
}
// GetFullGame returns a game with its full related data.
func (s *GameService) GetFullGame(
ctx context.Context,
id int,
@@ -141,6 +147,7 @@ func (s *GameService) GetFullGame(
return res, nil
}
// DeleteGame removes a game.
func (s *GameService) DeleteGame(
ctx context.Context,
id int,
@@ -148,6 +155,7 @@ func (s *GameService) DeleteGame(
return mapGameError(s.gamesRepo.DeleteGameByID(ctx, id))
}
// GetGames returns games matching the requested filters.
func (s *GameService) GetGames(
ctx context.Context,
) ([]*game_model.Game, error) {
@@ -158,6 +166,7 @@ func (s *GameService) GetGames(
return game_model.MapGames(games, s.domain), nil
}
// UpdateGame updates a game's editable fields.
func (s *GameService) UpdateGame(
ctx context.Context,
id int,
@@ -181,22 +190,27 @@ func (s *GameService) UpdateGame(
return mapGameError(s.gamesRepo.UpdateGame(ctx, id, name, description, location, startAt, scenarioID, format))
}
// StartGame marks a game as started.
func (s *GameService) StartGame(ctx context.Context, id int) error {
return mapGameError(s.gamesRepo.StartGame(ctx, id))
}
// PauseGame marks a game as paused.
func (s *GameService) PauseGame(ctx context.Context, id int) error {
return mapGameError(s.gamesRepo.PauseGame(ctx, id))
}
// PlayGame resumes a paused game.
func (s *GameService) PlayGame(ctx context.Context, id int) error {
return mapGameError(s.gamesRepo.PlayGame(ctx, id))
}
// FinishGame marks a game as finished.
func (s *GameService) FinishGame(ctx context.Context, id int) error {
return mapGameError(s.gamesRepo.FinishGame(ctx, id))
}
// ResetGame resets a game's state.
func (s *GameService) ResetGame(ctx context.Context, id int) error {
return mapGameError(s.gamesRepo.ResetGame(ctx, id))
}
@@ -15,10 +15,13 @@ import (
)
var (
// ErrTeamNotFound indicates that a team was not found.
ErrTeamNotFound = errors.New("Команда не найдена")
// ErrGameNotFound indicates that a game was not found.
ErrGameNotFound = errors.New("Игра не найдена")
)
// GameplayService handles team gameplay operations.
type GameplayService struct {
gamesRepo *games_repo.GamesRepo
teamsRepo *teams_repo.TeamsRepo
@@ -30,6 +33,7 @@ type GameplayService struct {
domain string
}
// NewGameplayService creates a gameplay service.
func NewGameplayService(
gamesRepo *games_repo.GamesRepo,
teamsRepo *teams_repo.TeamsRepo,
@@ -52,10 +56,12 @@ func NewGameplayService(
}
}
// GiveTeamApplications grants an application to a team.
func (s *GameplayService) GiveTeamApplications(ctx context.Context, teamID int, name string) error {
return mapGameplayError(s.applicationsRepo.UpdateApplicationStatus(ctx, teamID, name, "done"))
}
// GetTeamActions returns the current story, game, and available team actions.
func (s *GameplayService) GetTeamActions(ctx context.Context, teamID int, password string) (*storytelling.Story, *game_model.Game, []actions_repo.Action, error) {
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
if err != nil {
@@ -93,6 +99,7 @@ func (s *GameplayService) GetTeamActions(ctx context.Context, teamID int, passwo
return story, game_model.MapGame(game, s.domain), actions, nil
}
// AddTeamAction records an action performed by a team.
func (s *GameplayService) AddTeamAction(ctx context.Context, teamID int, password string, actionCode string, viaMCP bool) error {
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
if err != nil {
@@ -147,6 +154,7 @@ func (s *GameplayService) AddTeamAction(ctx context.Context, teamID int, passwor
return nil
}
// DeleteLastTeamAction removes the latest action for a team.
func (s *GameplayService) DeleteLastTeamAction(ctx context.Context, teamID int) error {
team, err := s.teamsRepo.GetTeamByID(ctx, teamID)
if err != nil {
+1
View File
@@ -3,6 +3,7 @@ package map_service
import "evening_detective_server/internal/repos/game_map_repo"
// MapItem describes an item placed on a game map.
type MapItem struct {
GameID int
Kind string
+7
View File
@@ -7,20 +7,25 @@ import (
"evening_detective_server/internal/repos/game_map_repo"
)
// ErrItemNotFound indicates that a map item was not found.
var ErrItemNotFound = errors.New("объект карты не найден")
// MapService handles game map items.
type MapService struct {
repo *game_map_repo.Repo
}
// NewMapService creates a map service.
func NewMapService(repo *game_map_repo.Repo) *MapService {
return &MapService{repo: repo}
}
// Ensure creates a map item if it does not exist.
func (s *MapService) Ensure(ctx context.Context, gameID int, kind string, itemID int) error {
return s.repo.Ensure(ctx, gameID, kind, itemID)
}
// List returns map items for a game.
func (s *MapService) List(ctx context.Context, gameID int) ([]*MapItem, error) {
items, err := s.repo.List(ctx, gameID)
if err != nil {
@@ -33,6 +38,7 @@ func (s *MapService) List(ctx context.Context, gameID int) ([]*MapItem, error) {
return result, nil
}
// Update changes a map item's position and visibility.
func (s *MapService) Update(ctx context.Context, item *MapItem) error {
if err := s.repo.Update(ctx, toRepoItem(item)); err != nil {
if errors.Is(err, game_map_repo.ErrItemNotFound) {
@@ -43,6 +49,7 @@ func (s *MapService) Update(ctx context.Context, item *MapItem) error {
return nil
}
// Delete removes a map item.
func (s *MapService) Delete(ctx context.Context, item *MapItem) error {
return s.repo.Delete(ctx, toRepoItem(item))
}
+2 -5
View File
@@ -1,8 +1,3 @@
// Пакет mcp_service собирает MCP-сервер (Model Context Protocol) для
// игры «Вечерний детектив»: инструменты MCP вызывают игровые сервисы
// напрямую (через интерфейс GamePlayer), без HTTP-прослойки. MCP-сервер
// монтируется в основной процесс как HTTP-эндпоинт /mcp (streamable HTTP)
// на REST-gateway.
// Package mcp_service exposes game operations through MCP.
package mcp_service
@@ -50,10 +45,12 @@ type MCPService struct {
answers AnswerSubmitter
}
// NewMCPService creates an MCP service.
func NewMCPService(game GamePlayer, answers AnswerSubmitter) *MCPService {
return &MCPService{game: game, answers: answers}
}
// Server returns the underlying MCP server.
func (s *MCPService) Server() *server.MCPServer {
srv := server.NewMCPServer(
"evening-detective-mcp",
@@ -19,8 +19,12 @@ type Participation struct {
}
var (
ErrParticipationLink = errors.New("Ссылка недействительна")
// ErrParticipationLink indicates that a participation link is invalid.
ErrParticipationLink = errors.New("Ссылка недействительна")
// ErrParticipationClosed indicates that participation answers are closed.
ErrParticipationClosed = errors.New("Приём ответов закрыт")
ErrParticipationFinal = errors.New("Решение уже принято и не может быть изменено")
ErrInvalidAnswer = errors.New("Выберите подтверждение или отмену участия")
// ErrParticipationFinal indicates that a final answer cannot be changed.
ErrParticipationFinal = errors.New("Решение уже принято и не может быть изменено")
// ErrInvalidAnswer indicates that the participation answer is invalid.
ErrInvalidAnswer = errors.New("Выберите подтверждение или отмену участия")
)
@@ -20,19 +20,24 @@ import (
"github.com/jackc/pgx/v5"
)
// ParticipationService handles team participation.
type ParticipationService struct {
teamsRepo *teams_repo.TeamsRepo
appBaseURL string
}
// NewParticipationService creates a participation service.
func NewParticipationService(teamsRepo *teams_repo.TeamsRepo, appBaseURL string) *ParticipationService {
return &ParticipationService{teamsRepo: teamsRepo, appBaseURL: appBaseURL}
}
// GetParticipation returns participation data by token.
func (s *ParticipationService) GetParticipation(ctx context.Context, token string) (*Participation, error) {
p, err := s.teamsRepo.GetParticipation(ctx, token)
return mapParticipation(p), mapParticipationError(err)
}
// AnswerParticipation stores a team's final answer.
func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) {
p, err := s.teamsRepo.AnswerParticipation(ctx, token, answer)
return mapParticipation(p), mapParticipationError(err)
@@ -76,6 +81,7 @@ func participationEmail(p *Participation, baseURL, token string, location *time.
return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup}
}
// RunParticipationEmails sends pending participation emails.
func (s *ParticipationService) RunParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret, timezone string) {
base, err := url.Parse(s.appBaseURL)
if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" || secret == "" {
@@ -7,6 +7,7 @@ import (
"time"
)
// Scenario describes a scenario exposed by the service layer.
type Scenario struct {
ID int
Name string
@@ -21,6 +21,7 @@ func mapScenariosLight(
return res
}
// MapScenarioLight converts a repository scenario to a lightweight model.
func MapScenarioLight(
o *repos.Scenario,
domain string,
@@ -28,6 +28,7 @@ type IScenariosRepository interface {
UpdateStoryByScenarioID(ctx context.Context, id int, story string) error
}
// ScenarioService handles scenario operations.
type ScenarioService struct {
scenariosRepo IScenariosRepository
cleaner cleaner.ICleaner
@@ -36,6 +37,7 @@ type ScenarioService struct {
domain string
}
// NewScenarioService creates a scenario service.
func NewScenarioService(
scenariosRepo IScenariosRepository,
cleaner cleaner.ICleaner,
@@ -52,6 +54,7 @@ func NewScenarioService(
}
}
// AddScenario creates a scenario.
func (s *ScenarioService) AddScenario(
ctx context.Context,
name string,
@@ -64,6 +67,7 @@ func (s *ScenarioService) AddScenario(
return id, nil
}
// GetScenariosByAuthorID returns scenarios created by an author.
func (s *ScenarioService) GetScenariosByAuthorID(
ctx context.Context,
authorID int,
@@ -75,6 +79,7 @@ func (s *ScenarioService) GetScenariosByAuthorID(
return mapScenariosLight(scenarios, s.domain), nil
}
// GetScenariosCatalog returns scenarios available in the catalog.
func (s *ScenarioService) GetScenariosCatalog(
ctx context.Context,
) ([]*Scenario, error) {
@@ -85,6 +90,7 @@ func (s *ScenarioService) GetScenariosCatalog(
return mapScenariosLight(scenarios, s.domain), nil
}
// GetFullScenarioByID returns a scenario with its full story.
func (s *ScenarioService) GetFullScenarioByID(
ctx context.Context,
id int,
@@ -96,6 +102,7 @@ func (s *ScenarioService) GetFullScenarioByID(
return mapScenario(scenario, s.domain)
}
// GetScenarioByID returns a scenario by ID.
func (s *ScenarioService) GetScenarioByID(
ctx context.Context,
id int,
@@ -107,6 +114,7 @@ func (s *ScenarioService) GetScenarioByID(
return MapScenarioLight(scenario, s.domain), nil
}
// UpdateScenarioByID updates a scenario's metadata.
func (s *ScenarioService) UpdateScenarioByID(
ctx context.Context,
id int,
@@ -177,6 +185,7 @@ func mapScenarioError(err error) error {
return err
}
// UpdateScenarioStatusByID changes a scenario's publication status.
func (s *ScenarioService) UpdateScenarioStatusByID(
ctx context.Context,
id int,
@@ -208,6 +217,7 @@ func (s *ScenarioService) UpdateScenarioStatusByID(
return nil
}
// DeleteScenarioByID removes a scenario.
func (s *ScenarioService) DeleteScenarioByID(
ctx context.Context,
id int,
@@ -229,6 +239,7 @@ func (s *ScenarioService) DeleteScenarioByID(
return nil
}
// AddScenarioPlace adds a place to a scenario story.
func (s *ScenarioService) AddScenarioPlace(
ctx context.Context,
id int,
@@ -250,6 +261,7 @@ func (s *ScenarioService) AddScenarioPlace(
return s.updateStory(ctx, id, story)
}
// UpdateScenarioPlace updates a place in a scenario story.
func (s *ScenarioService) UpdateScenarioPlace(
ctx context.Context,
id int,
@@ -274,6 +286,7 @@ func (s *ScenarioService) UpdateScenarioPlace(
return s.updateStory(ctx, id, story)
}
// DeleteScenarioPlace removes a place from a scenario story.
func (s *ScenarioService) DeleteScenarioPlace(
ctx context.Context,
id int,
@@ -332,6 +345,7 @@ func (s *ScenarioService) UpdateScenarioIntro(
return s.updateStory(ctx, id, story)
}
// UpdateScenarioConclusion updates a scenario conclusion.
func (s *ScenarioService) UpdateScenarioConclusion(ctx context.Context, id int, conclusion *storytelling.Conclusion, actorID int, isAdmin bool) error {
if _, err := s.getScenarioForChange(ctx, id, actorID, isAdmin); err != nil {
return err
@@ -7,6 +7,7 @@ import (
"evening_detective_server/internal/repos/screen_sessions_repo"
)
// ScreenSession describes a screen session exposed by the service layer.
type ScreenSession struct {
ID int
GameID *int
@@ -7,20 +7,25 @@ import (
"evening_detective_server/internal/repos/screen_sessions_repo"
)
// ErrScreenNotFound indicates that a screen session was not found.
var ErrScreenNotFound = errors.New("Экран не найден")
// ScreenService handles screen sessions.
type ScreenService struct {
repo *screen_sessions_repo.ScreenSessionsRepo
}
// NewScreenService creates a screen service.
func NewScreenService(repo *screen_sessions_repo.ScreenSessionsRepo) *ScreenService {
return &ScreenService{repo: repo}
}
// Create creates a screen session.
func (s *ScreenService) Create(ctx context.Context, tokenHash string) (int, error) {
return s.repo.Create(ctx, tokenHash)
}
// Get returns a screen session.
func (s *ScreenService) Get(ctx context.Context, id int, tokenHash string) (*ScreenSession, error) {
session, err := s.repo.Get(ctx, id, tokenHash)
if err != nil {
@@ -29,6 +34,7 @@ func (s *ScreenService) Get(ctx context.Context, id int, tokenHash string) (*Scr
return mapSession(session), nil
}
// ListByGame returns screen sessions for a game.
func (s *ScreenService) ListByGame(ctx context.Context, gameID int) ([]*ScreenSession, error) {
sessions, err := s.repo.ListByGame(ctx, gameID)
if err != nil {
@@ -41,30 +47,37 @@ func (s *ScreenService) ListByGame(ctx context.Context, gameID int) ([]*ScreenSe
return result, nil
}
// SetMode updates the display mode for a game.
func (s *ScreenService) SetMode(ctx context.Context, gameID int, mode int32) error {
return s.repo.SetMode(ctx, gameID, mode)
}
// SetTimer updates the display timer for a game.
func (s *ScreenService) SetTimer(ctx context.Context, gameID int, duration int64, label string) error {
return s.repo.SetTimer(ctx, gameID, duration, label)
}
// SetTimerCommand updates the timer command for a game.
func (s *ScreenService) SetTimerCommand(ctx context.Context, gameID int, command int32) error {
return s.repo.SetTimerCommand(ctx, gameID, command)
}
// SetCommand updates the display command for a game.
func (s *ScreenService) SetCommand(ctx context.Context, gameID int, command int32, position int64, volume int32) error {
return s.repo.SetCommand(ctx, gameID, command, position, volume)
}
// Bind attaches a screen session to a game.
func (s *ScreenService) Bind(ctx context.Context, id, gameID int, tokenHash string) error {
return mapError(s.repo.Bind(ctx, id, gameID, tokenHash))
}
// Unbind detaches a screen session from its game.
func (s *ScreenService) Unbind(ctx context.Context, id int) error {
return mapError(s.repo.Unbind(ctx, id))
}
// SetSound enables or disables sound for a screen session.
func (s *ScreenService) SetSound(ctx context.Context, id int, enabled bool) error {
return mapError(s.repo.SetSound(ctx, id, enabled))
}
@@ -21,12 +21,14 @@ var (
ErrRegistrationClosed = errors.New("Игра не найдена или регистрация уже закрыта")
)
// Registration contains data required to register a team.
type Registration struct {
Name string `json:"name"`
Email string `json:"email"`
ParticipantsCount int32 `json:"participantsCount"`
}
// UnmarshalJSON decodes and normalizes registration data.
func (r *Registration) UnmarshalJSON(data []byte) error {
var payload struct {
Name string `json:"name"`
@@ -63,6 +65,7 @@ func validCaptainEmail(email string) bool {
return err == nil && address.Address == email && len(email) <= 254
}
// Validate checks registration data.
func (r *Registration) Validate() error {
r.Name = strings.TrimSpace(r.Name)
r.Email = strings.TrimSpace(r.Email)
@@ -17,6 +17,7 @@ import (
"evening_detective_server/internal/services/game_model"
)
// TeamService handles team operations.
type TeamService struct {
gamesRepo *games_repo.GamesRepo
teamsRepo *teams_repo.TeamsRepo
@@ -25,6 +26,7 @@ type TeamService struct {
emailSender email_sender.IEmailSender
}
// NewTeamService creates a team service.
func NewTeamService(
gamesRepo *games_repo.GamesRepo,
teamsRepo *teams_repo.TeamsRepo,
@@ -41,6 +43,7 @@ func NewTeamService(
}
}
// AddTeam creates a team.
func (s *TeamService) AddTeam(
ctx context.Context,
creatorID int,
@@ -73,6 +76,7 @@ func (s *TeamService) AddTeam(
}, nil
}
// UpdateTeam changes a team's name.
func (s *TeamService) UpdateTeam(ctx context.Context, teamID int, name string) error {
err := s.teamsRepo.UpdateTeam(ctx, teamID, name)
if errors.Is(err, teams_repo.ErrTeamNotFound) {
@@ -81,6 +85,7 @@ func (s *TeamService) UpdateTeam(ctx context.Context, teamID int, name string) e
return err
}
// DeleteTeam removes a team.
func (s *TeamService) DeleteTeam(ctx context.Context, teamID int) error {
err := s.teamsRepo.DeleteTeam(ctx, teamID)
if errors.Is(err, teams_repo.ErrTeamNotFound) {
@@ -89,6 +94,7 @@ func (s *TeamService) DeleteTeam(ctx context.Context, teamID int) error {
return err
}
// RegisterTeam registers a team for a game.
func (s *TeamService) RegisterTeam(ctx context.Context, gameID int, deliveryID string, registration Registration) (int, error) {
if err := registration.Validate(); err != nil {
return 0, err
@@ -133,6 +139,7 @@ func registrationEmail(team *repos.Team, gameName, baseURL string) email_sender.
return email_sender.Message{To: team.CaptainEmail, Subject: "Регистрация команды в «Вечернем детективе»", Body: body, HTML: markup}
}
// DownloadTeamQR returns a PDF with team QR codes.
func (s *TeamService) DownloadTeamQR(ctx context.Context, id int) ([]byte, string, error) {
game, err := s.gamesRepo.GetGameByID(ctx, id)
if err != nil {