This commit is contained in:
2026-09-16 02:44:44 +07:00
parent 27db2067e3
commit 0e42278b36
25 changed files with 1781 additions and 424 deletions
+11 -7
View File
@@ -1,11 +1,15 @@
package repos
type Team struct {
CaptainEmail string
ParticipantsCount int32
ID int
Name string
Status string
Password string
GameId int
ParticipationStatus string
ParticipationSource string
ParticipationEmailError string
ParticipationEmailSent bool
CaptainEmail string
ParticipantsCount int32
ID int
Name string
Status string
Password string
GameId int
}
+129
View File
@@ -0,0 +1,129 @@
package teams_repo
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/jackc/pgx/v5"
)
var ErrParticipationLink = errors.New("Ссылка недействительна")
var ErrParticipationClosed = errors.New("Приём ответов закрыт")
var ErrParticipationFinal = errors.New("Решение уже принято и не может быть изменено")
const maxParticipationEmailAttempts = 8
type Participation struct {
TeamID int
TeamName string
GameName string
StartAt time.Time
ParticipantsCount int32
Status string
Closed bool
Email string
Attempts int
}
func tokenHash(token string) ([]byte, error) {
if len(token) != 64 {
return nil, ErrParticipationLink
}
raw, err := hex.DecodeString(token)
if err != nil || len(raw) != 32 {
return nil, ErrParticipationLink
}
hash := sha256.Sum256([]byte(token))
return hash[:], nil
}
func (r *TeamsRepo) GetParticipation(ctx context.Context, token string) (*Participation, error) {
hash, err := tokenHash(token)
if err != nil {
return nil, err
}
p := &Participation{}
err = r.pool.QueryRow(ctx, `SELECT t.id, t.name, g.name, g.start_at, t.participants_count, t.participation_status,
g.status <> 'draft' OR g.start_at <= (NOW() AT TIME ZONE 'UTC')
FROM teams t JOIN games g ON g.id=t.game_id WHERE t.participation_token_hash=$1`, hash).Scan(
&p.TeamID, &p.TeamName, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Status, &p.Closed)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrParticipationLink
}
return p, err
}
func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) {
if answer != "confirmed" && answer != "cancelled" {
return nil, errors.New("Выберите подтверждение или отмену участия")
}
hash, err := tokenHash(token)
if err != nil {
return nil, err
}
// The condition is rechecked after a concurrent answer releases its row lock.
_, err = r.pool.Exec(ctx, `WITH eligible_game AS (SELECT g.id FROM games g JOIN teams t ON t.game_id=g.id
WHERE t.participation_token_hash=$1 AND g.status='draft' AND g.start_at > (clock_timestamp() AT TIME ZONE 'UTC') FOR SHARE OF g)
UPDATE teams t SET participation_status=$2,
participation_source='captain', participation_answered_at=NOW()
FROM eligible_game g WHERE g.id=t.game_id AND t.participation_token_hash=$1
AND t.participation_status='pending'`, hash, answer)
if err != nil {
return nil, err
}
p, err := r.GetParticipation(ctx, token)
if err != nil {
return nil, err
}
if p.Status == answer {
return p, nil
}
if p.Status != "pending" {
return p, ErrParticipationFinal
}
return p, ErrParticipationClosed
}
// A five-minute lease survives restarts and keeps concurrent workers from sending the same job.
func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) {
p := &Participation{}
err := r.pool.QueryRow(ctx, `WITH candidate AS (
SELECT t.id FROM teams t JOIN games g ON g.id=t.game_id
WHERE t.participation_status='pending' AND t.captain_email<>'' AND t.participation_sent_at IS NULL
AND t.participation_attempts < $1
AND (t.participation_next_attempt_at IS NULL OR t.participation_next_attempt_at <= NOW())
AND g.status='draft' AND g.start_at > (NOW() AT TIME ZONE 'UTC')
AND g.start_at <= (NOW() AT TIME ZONE 'UTC') + INTERVAL '24 hours'
ORDER BY t.participation_next_attempt_at NULLS FIRST, t.id
FOR UPDATE OF t SKIP LOCKED LIMIT 1
) UPDATE teams t SET participation_next_attempt_at=NOW()+INTERVAL '5 minutes',
participation_attempts=t.participation_attempts+1, participation_email_error=''
FROM candidate c, games g WHERE t.id=c.id AND g.id=t.game_id
RETURNING t.id,t.name,t.captain_email,g.name,g.start_at,t.participants_count,t.participation_attempts`, maxParticipationEmailAttempts).Scan(
&p.TeamID, &p.TeamName, &p.Email, &p.GameName, &p.StartAt, &p.ParticipantsCount, &p.Attempts)
return p, err
}
func (r *TeamsRepo) SetParticipationToken(ctx context.Context, id int, token string) error {
hash, err := tokenHash(token)
if err != nil {
return err
}
_, err = r.pool.Exec(ctx, `UPDATE teams SET participation_token_hash=$2 WHERE id=$1`, id, hash)
return err
}
func (r *TeamsRepo) FinishParticipationEmail(ctx context.Context, id, attempt int, sendErr error) error {
message := ""
if sendErr != nil {
message = sendErr.Error()
}
_, err := r.pool.Exec(ctx, `UPDATE teams SET participation_email_error=$3,
participation_sent_at=CASE WHEN $3='' THEN NOW() ELSE participation_sent_at END
WHERE id=$1 AND participation_attempts=$2`, id, attempt, message)
return err
}
@@ -58,6 +58,13 @@ func TestRegistrationPostgres(t *testing.T) {
if _, err := pool.Exec(ctx, strings.Split(string(migration), "-- +goose Down")[0]); err != nil {
t.Fatal(err)
}
participationMigration, err := os.ReadFile("../../../migrations/20260915120000_add_team_participation.sql")
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, strings.Split(string(participationMigration), "-- +goose Down")[0]); err != nil {
t.Fatal(err)
}
repo := NewTeamsRepo(pool)
type result struct {
id int
+39 -5
View File
@@ -32,6 +32,7 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
name,
captain_email,
participants_count,
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
status,
password,
game_id
@@ -44,6 +45,7 @@ func (r *TeamsRepo) GetTeamByID(ctx context.Context, id int) (*repos.Team, error
&team.Name,
&team.CaptainEmail,
&team.ParticipantsCount,
&team.ParticipationStatus, &team.ParticipationSource, &team.ParticipationEmailError, &team.ParticipationEmailSent,
&team.Status,
&team.Password,
&team.GameId,
@@ -69,6 +71,7 @@ func (r *TeamsRepo) GetTeamsByGameID(
name,
captain_email,
participants_count,
participation_status, participation_source, participation_email_error, participation_sent_at IS NOT NULL,
status,
password
FROM teams
@@ -89,6 +92,7 @@ func (r *TeamsRepo) GetTeamsByGameID(
&team.Name,
&team.CaptainEmail,
&team.ParticipantsCount,
&team.ParticipationStatus, &team.ParticipationSource, &team.ParticipationEmailError, &team.ParticipationEmailSent,
&team.Status,
&team.Password,
)
@@ -111,16 +115,26 @@ func (r *TeamsRepo) AddTeam(
name string,
password string,
) (int, error) {
return r.AddTeamWithParticipation(ctx, creatorId, gameId, name, password, false)
}
func (r *TeamsRepo) AddTeamWithParticipation(ctx context.Context, creatorId, gameId int, name, password string, autoConfirm bool) (int, error) {
id := 0
status, source := "pending", ""
if autoConfirm {
status, source = "confirmed", "automatic"
}
err := r.pool.QueryRow(
ctx,
`INSERT INTO teams (name, creator_id, game_id, password)
VALUES ($1, $2, $3, $4)
`INSERT INTO teams (name, creator_id, game_id, password, participation_status, participation_source, participation_answered_at)
VALUES ($1, $2, $3, $4, $5, $6, CASE WHEN $5 = 'confirmed' THEN NOW() ELSE NULL END)
RETURNING id`,
name,
creatorId,
gameId,
password,
status,
source,
).Scan(&id)
if err != nil {
@@ -168,6 +182,10 @@ var ErrRegistrationClosed = errors.New("Игра не найдена или ре
// RegisterTeam сохраняет одну команду на доставку, включая одновременные повторы.
func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string) (int, error) {
return r.RegisterTeamWithParticipation(ctx, gameID, deliveryID, name, email, participantsCount, password, false)
}
func (r *TeamsRepo) RegisterTeamWithParticipation(ctx context.Context, gameID int, deliveryID, name, email string, participantsCount int32, password string, autoConfirm bool) (int, error) {
var id int
// Повтор уже принятой доставки успешен и после старта игры.
err := r.pool.QueryRow(ctx, `SELECT id FROM teams WHERE game_id = $1 AND registration_delivery_id = $2`, gameID, deliveryID).Scan(&id)
@@ -177,14 +195,30 @@ func (r *TeamsRepo) RegisterTeam(ctx context.Context, gameID int, deliveryID, na
if !errors.Is(err, pgx.ErrNoRows) {
return 0, err
}
status, source := "pending", ""
if autoConfirm {
status, source = "confirmed", "automatic"
}
err = r.pool.QueryRow(ctx, `
INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password)
SELECT id, $2, $3, $4, $5, $6 FROM games WHERE id = $1 AND status = 'draft'
INSERT INTO teams (game_id, registration_delivery_id, name, captain_email, participants_count, password, participation_status, participation_source, participation_answered_at)
SELECT id, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $7 = 'confirmed' THEN NOW() ELSE NULL END FROM games WHERE id = $1 AND status = 'draft'
ON CONFLICT (game_id, registration_delivery_id)
DO UPDATE SET registration_delivery_id = EXCLUDED.registration_delivery_id
RETURNING id`, gameID, deliveryID, name, email, participantsCount, password).Scan(&id)
RETURNING id`, gameID, deliveryID, name, email, participantsCount, password, status, source).Scan(&id)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrRegistrationClosed
}
return id, err
}
// GetActiveTeamsByGameID excludes cancelled registrations from gameplay exports and results.
func (r *TeamsRepo) GetActiveTeamsByGameID(ctx context.Context, gameID int) ([]*repos.Team, error) {
teams, err := r.GetTeamsByGameID(ctx, gameID)
active := teams[:0]
for _, team := range teams {
if team.ParticipationStatus != "cancelled" {
active = append(active, team)
}
}
return active, err
}