From 49fe193ed71b6f135e6a87fc7a1cce2cbd317bc7 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Thu, 17 Sep 2026 01:55:28 +0700 Subject: [PATCH] up --- cmd/evening_detective_server/main.go | 5 ++- internal/app/participation.go | 4 +- internal/app/server.go | 32 +++++++++------- .../participation_test.go | 37 ++++++++++--------- .../service.go} | 19 +++++++--- 5 files changed, 58 insertions(+), 39 deletions(-) rename internal/services/{game_service => participation_service}/participation_test.go (80%) rename internal/services/{game_service/participation.go => participation_service/service.go} (84%) diff --git a/cmd/evening_detective_server/main.go b/cmd/evening_detective_server/main.go index 971c0aa..63ef046 100644 --- a/cmd/evening_detective_server/main.go +++ b/cmd/evening_detective_server/main.go @@ -27,6 +27,7 @@ import ( "evening_detective_server/internal/services/file_service" "evening_detective_server/internal/services/game_service" "evening_detective_server/internal/services/mcp_service" + "evening_detective_server/internal/services/participation_service" "evening_detective_server/internal/services/scenarios_service" "evening_detective_server/internal/services/ui_service" "evening_detective_server/internal/services/users_service" @@ -148,7 +149,8 @@ func main() { os.Getenv("APP_BASE_URL"), ) - go gameService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE")) + participationService := participation_service.NewParticipationService(teamRepo, os.Getenv("APP_BASE_URL")) + go participationService.RunParticipationEmails(context.Background(), emailSender, os.Getenv("PARTICIPATION_SECRET"), os.Getenv("GAME_TIMEZONE")) // Create a listener on TCP port lis, err := net.Listen("tcp", ":8080") @@ -197,6 +199,7 @@ func main() { fileService, scenarioService, gameService, + participationService, screenRepo, mapRepo, ), diff --git a/internal/app/participation.go b/internal/app/participation.go index f3a9cae..9994a7c 100644 --- a/internal/app/participation.go +++ b/internal/app/participation.go @@ -31,7 +31,7 @@ func participationError(err error) string { } func (s *server) GetParticipation(ctx context.Context, req *proto.GetParticipationReq) (*proto.ParticipationRsp, error) { _ = grpc.SetHeader(ctx, metadata.Pairs("Cache-Control", "no-store")) - p, err := s.gameService.GetParticipation(ctx, req.Token) + p, err := s.participationService.GetParticipation(ctx, req.Token) result := mapParticipationResponse(p) if err != nil { result.Error = participationError(err) @@ -43,7 +43,7 @@ func (s *server) AnswerParticipation(ctx context.Context, req *proto.AnswerParti if req.Answer != "confirmed" && req.Answer != "cancelled" { return &proto.ParticipationRsp{Error: "Выберите подтверждение или отмену участия"}, nil } - p, err := s.gameService.AnswerParticipation(ctx, req.Token, req.Answer) + p, err := s.participationService.AnswerParticipation(ctx, req.Token, req.Answer) result := mapParticipationResponse(p) if err != nil { result.Error = participationError(err) diff --git a/internal/app/server.go b/internal/app/server.go index 06d83c6..73e7a41 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -6,6 +6,7 @@ import ( "evening_detective_server/internal/repos/screen_sessions_repo" "evening_detective_server/internal/services/file_service" "evening_detective_server/internal/services/game_service" + "evening_detective_server/internal/services/participation_service" "evening_detective_server/internal/services/scenarios_service" "evening_detective_server/internal/services/ui_service" "evening_detective_server/internal/services/users_service" @@ -18,13 +19,14 @@ var stringTools = string_tools.NewStringTools() type server struct { proto.UnsafeEveningDetectiveServerServer - usersService *users_service.UsersService - uiService *ui_service.UiService - fileService *file_service.FileService - scenarioService *scenarios_service.ScenarioService - gameService *game_service.GameService - screenRepo *screen_sessions_repo.ScreenSessionsRepo - mapRepo *game_map_repo.Repo + usersService *users_service.UsersService + uiService *ui_service.UiService + fileService *file_service.FileService + scenarioService *scenarios_service.ScenarioService + gameService *game_service.GameService + participationService *participation_service.ParticipationService + screenRepo *screen_sessions_repo.ScreenSessionsRepo + mapRepo *game_map_repo.Repo } func NewServer( @@ -33,16 +35,18 @@ func NewServer( fileService *file_service.FileService, scenarioService *scenarios_service.ScenarioService, gameService *game_service.GameService, + participationService *participation_service.ParticipationService, screenRepo *screen_sessions_repo.ScreenSessionsRepo, mapRepo *game_map_repo.Repo, ) proto.EveningDetectiveServerServer { return &server{ - usersService: usersService, - uiService: uiService, - fileService: fileService, - scenarioService: scenarioService, - gameService: gameService, - screenRepo: screenRepo, - mapRepo: mapRepo, + usersService: usersService, + uiService: uiService, + fileService: fileService, + scenarioService: scenarioService, + gameService: gameService, + participationService: participationService, + screenRepo: screenRepo, + mapRepo: mapRepo, } } diff --git a/internal/services/game_service/participation_test.go b/internal/services/participation_service/participation_test.go similarity index 80% rename from internal/services/game_service/participation_test.go rename to internal/services/participation_service/participation_test.go index 972344b..27afbb1 100644 --- a/internal/services/game_service/participation_test.go +++ b/internal/services/participation_service/participation_test.go @@ -1,4 +1,4 @@ -package game_service +package participation_service import ( "context" @@ -12,6 +12,7 @@ import ( "evening_detective_server/internal/modules/email_sender" "evening_detective_server/internal/repos/teams_repo" + "evening_detective_server/internal/services/game_service" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) @@ -88,7 +89,7 @@ func TestParticipationPostgres(t *testing.T) { exec(strings.Split(string(data), "-- +goose Down")[0]) } repo := teams_repo.NewTeamsRepo(pool) - service := &GameService{teamsRepo: repo, appBaseURL: "https://example.org"} + service := NewParticipationService(repo, "https://example.org") state := func(id int, want string) { t.Helper() team, err := repo.GetTeamByID(ctx, id) @@ -98,9 +99,10 @@ func TestParticipationPostgres(t *testing.T) { } state(1, "pending") state(2, "pending") // Existing teams are not changed by the migration. - exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5; + // Model registration output explicitly: direct SQL inserts do not auto-confirm teams. + exec(`UPDATE teams SET captain_email='captain@example.org',participants_count=5 WHERE id=1; INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours','boundary@example.org' FROM games WHERE id=1; - INSERT INTO teams(game_id,created_at,captain_email) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org' FROM games WHERE id=1; + INSERT INTO teams(game_id,created_at,captain_email,participation_status,participation_source,participation_answered_at) SELECT 1,start_at-INTERVAL '24 hours'+INTERVAL '1 microsecond','late@example.org','confirmed','automatic',NOW() FROM games WHERE id=1; INSERT INTO teams(game_id,captain_email) VALUES(2,'future@example.org'); INSERT INTO teams(game_id,created_at) SELECT 1,start_at-INTERVAL '25 hours' FROM games WHERE id=1;`) state(3, "pending") @@ -156,7 +158,7 @@ func TestParticipationPostgres(t *testing.T) { } sender.err = nil // Expired lease is recovered after a worker restart. - exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id=3`) + exec(`UPDATE teams SET participation_next_attempt_at=NOW()-INTERVAL '1 minute' WHERE id IN (1,3)`) if err := service.sendParticipationEmails(ctx, sender, "secret", time.UTC); err != nil { t.Fatal(err) } @@ -171,19 +173,19 @@ func TestParticipationPostgres(t *testing.T) { t.Fatal("email retry exceeded the eight-attempt limit") } token := participationToken("secret", 1) - if _, err := repo.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) { + if _, err := service.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) { t.Fatalf("invalid token: %v", err) } - if _, err := repo.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) { + if _, err := service.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) { t.Fatalf("unknown token: %v", err) } - if _, err := repo.GetParticipation(ctx, token); err != nil { + if _, err := service.GetParticipation(ctx, token); err != nil { t.Fatal(err) } state(1, "pending") // Viewing a link never answers. answers := make(chan error, 2) for _, answer := range []string{"confirmed", "cancelled"} { - go func() { _, err := repo.AnswerParticipation(ctx, token, answer); answers <- err }() + go func() { _, err := service.AnswerParticipation(ctx, token, answer); answers <- err }() } success, conflict := 0, 0 for range 2 { @@ -199,16 +201,16 @@ func TestParticipationPostgres(t *testing.T) { if success != 1 || conflict != 1 { t.Fatalf("concurrent answers: success=%d conflict=%d", success, conflict) } - p, err := repo.GetParticipation(ctx, token) + p, err := service.GetParticipation(ctx, token) if err != nil { t.Fatal(err) } - if _, err := repo.AnswerParticipation(ctx, token, p.Status); err != nil { + if _, err := service.AnswerParticipation(ctx, token, p.Status); err != nil { t.Fatalf("same answer not idempotent: %v", err) } // Cancellation excludes teams from shared active listings and password-based gameplay. token3 := participationToken("secret", 3) - if _, err := repo.AnswerParticipation(ctx, token3, "cancelled"); err != nil { + if _, err := service.AnswerParticipation(ctx, token3, "cancelled"); err != nil { t.Fatal(err) } active, err := repo.GetActiveTeamsByGameID(ctx, 1) @@ -220,13 +222,14 @@ func TestParticipationPostgres(t *testing.T) { t.Fatal("cancelled team in active list") } } - if _, _, _, err := service.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { + gameService := game_service.NewGameService(nil, repo, nil, nil, "", nil, nil, nil, nil, nil, nil, nil, "") + if _, _, _, err := gameService.GetTeamActions(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { t.Fatalf("cancelled team got story: %v", err) } - if err := service.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) { + if err := gameService.AddTeamAction(ctx, 3, "secret", "anything", false); !errors.Is(err, teams_repo.ErrTeamNotFound) { t.Fatalf("cancelled team added action: %v", err) } - if _, err := service.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { + if _, err := gameService.GetTeamAnswers(ctx, 3, "secret"); !errors.Is(err, teams_repo.ErrTeamNotFound) { t.Fatalf("cancelled team got answers: %v", err) } token6 := participationToken("secret", 6) @@ -234,11 +237,11 @@ func TestParticipationPostgres(t *testing.T) { t.Fatal(err) } exec(`UPDATE games SET status='run' WHERE id=1`) - if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) { + if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) { t.Fatalf("running game accepted answer: %v", err) } exec(`UPDATE games SET status='draft',start_at=(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 second' WHERE id=1`) - if _, err := repo.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) { + if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) { t.Fatalf("past scheduled start accepted answer: %v", err) } state(6, "pending") diff --git a/internal/services/game_service/participation.go b/internal/services/participation_service/service.go similarity index 84% rename from internal/services/game_service/participation.go rename to internal/services/participation_service/service.go index dd90a01..12d6fd6 100644 --- a/internal/services/game_service/participation.go +++ b/internal/services/participation_service/service.go @@ -1,4 +1,4 @@ -package game_service +package participation_service import ( "context" @@ -19,10 +19,19 @@ import ( "github.com/jackc/pgx/v5" ) -func (s *GameService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) { +type ParticipationService struct { + teamsRepo *teams_repo.TeamsRepo + appBaseURL string +} + +func NewParticipationService(teamsRepo *teams_repo.TeamsRepo, appBaseURL string) *ParticipationService { + return &ParticipationService{teamsRepo: teamsRepo, appBaseURL: appBaseURL} +} + +func (s *ParticipationService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) { return s.teamsRepo.GetParticipation(ctx, token) } -func (s *GameService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) { +func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) { return s.teamsRepo.AnswerParticipation(ctx, token, answer) } @@ -46,7 +55,7 @@ func participationEmail(p *teams_repo.Participation, baseURL, token string, loca return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup} } -func (s *GameService) RunParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret, timezone string) { +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 == "" { log.Print("participation email worker disabled: configure APP_BASE_URL and PARTICIPATION_SECRET") @@ -74,7 +83,7 @@ func (s *GameService) RunParticipationEmails(ctx context.Context, sender email_s } } -func (s *GameService) sendParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret string, location *time.Location) error { +func (s *ParticipationService) sendParticipationEmails(ctx context.Context, sender email_sender.IEmailSender, secret string, location *time.Location) error { for ctx.Err() == nil { jobCtx, cancel := context.WithTimeout(ctx, time.Minute) p, err := s.teamsRepo.ClaimParticipationEmail(jobCtx)