This commit is contained in:
2026-10-02 01:06:19 +07:00
parent d85967968f
commit cbc8656d3e
5 changed files with 64 additions and 15 deletions
+3 -3
View File
@@ -3,14 +3,14 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"evening_detective_server/internal/repos/teams_repo" "evening_detective_server/internal/services/participation_service"
"evening_detective_server/proto" "evening_detective_server/proto"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/metadata" "google.golang.org/grpc/metadata"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
func mapParticipationResponse(p *teams_repo.Participation) *proto.ParticipationRsp { func mapParticipationResponse(p *participation_service.Participation) *proto.ParticipationRsp {
result := &proto.ParticipationRsp{} result := &proto.ParticipationRsp{}
if p != nil { if p != nil {
result.TeamName = p.TeamName result.TeamName = p.TeamName
@@ -24,7 +24,7 @@ func mapParticipationResponse(p *teams_repo.Participation) *proto.ParticipationR
} }
func participationError(err error) string { func participationError(err error) string {
if errors.Is(err, teams_repo.ErrParticipationLink) || errors.Is(err, teams_repo.ErrParticipationFinal) || errors.Is(err, teams_repo.ErrParticipationClosed) { if errors.Is(err, participation_service.ErrParticipationLink) || errors.Is(err, participation_service.ErrParticipationFinal) || errors.Is(err, participation_service.ErrParticipationClosed) {
return err.Error() return err.Error()
} }
return "Не удалось обработать ответ. Попробуйте позже." return "Не удалось обработать ответ. Попробуйте позже."
@@ -0,0 +1,20 @@
package participation_service
import "evening_detective_server/internal/repos/teams_repo"
func mapParticipation(p *teams_repo.Participation) *Participation {
if p == nil {
return nil
}
return &Participation{
TeamID: p.TeamID,
TeamName: p.TeamName,
GameName: p.GameName,
StartAt: p.StartAt,
ParticipantsCount: p.ParticipantsCount,
Status: p.Status,
Closed: p.Closed,
Email: p.Email,
Attempts: p.Attempts,
}
}
@@ -0,0 +1,26 @@
package participation_service
import (
"time"
"evening_detective_server/internal/repos/teams_repo"
)
// Participation — данные участия, доступные транспортному и email-сценариям.
type Participation struct {
TeamID int
TeamName string
GameName string
StartAt time.Time
ParticipantsCount int32
Status string
Closed bool
Email string
Attempts int
}
var (
ErrParticipationLink = teams_repo.ErrParticipationLink
ErrParticipationClosed = teams_repo.ErrParticipationClosed
ErrParticipationFinal = teams_repo.ErrParticipationFinal
)
@@ -33,7 +33,7 @@ func TestParticipationEmail(t *testing.T) {
if len(token) != 64 || token != participationToken("test-secret", 42) || token == participationToken("test-secret", 43) || token == participationToken("other", 42) { if len(token) != 64 || token != participationToken("test-secret", 42) || token == participationToken("test-secret", 43) || token == participationToken("other", 42) {
t.Fatal("token isolation or retry stability broken") t.Fatal("token isolation or retry stability broken")
} }
p := &teams_repo.Participation{TeamName: "<script>team</script>", GameName: "Game", Email: "captain@example.org", StartAt: time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), ParticipantsCount: 5} p := &Participation{TeamName: "<script>team</script>", GameName: "Game", Email: "captain@example.org", StartAt: time.Date(2026, 9, 20, 12, 0, 0, 0, time.UTC), ParticipantsCount: 5}
m := participationEmail(p, "https://example.org", token, time.FixedZone("+07", 7*3600)) m := participationEmail(p, "https://example.org", token, time.FixedZone("+07", 7*3600))
if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "&lt;script&gt;") || !strings.Contains(m.Body, "19:00") || !strings.Contains(m.Body, "#token="+token) || !strings.Contains(m.HTML, "answer=cancelled") { if strings.Contains(m.HTML, "<script>") || !strings.Contains(m.HTML, "&lt;script&gt;") || !strings.Contains(m.Body, "19:00") || !strings.Contains(m.Body, "#token="+token) || !strings.Contains(m.HTML, "answer=cancelled") {
t.Fatalf("invalid email: %+v", m) t.Fatalf("invalid email: %+v", m)
@@ -174,10 +174,10 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal("email retry exceeded the eight-attempt limit") t.Fatal("email retry exceeded the eight-attempt limit")
} }
token := participationToken("secret", 1) token := participationToken("secret", 1)
if _, err := service.GetParticipation(ctx, "bad"); !errors.Is(err, teams_repo.ErrParticipationLink) { if _, err := service.GetParticipation(ctx, "bad"); !errors.Is(err, ErrParticipationLink) {
t.Fatalf("invalid token: %v", err) t.Fatalf("invalid token: %v", err)
} }
if _, err := service.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, teams_repo.ErrParticipationLink) { if _, err := service.GetParticipation(ctx, strings.Repeat("0", 64)); !errors.Is(err, ErrParticipationLink) {
t.Fatalf("unknown token: %v", err) t.Fatalf("unknown token: %v", err)
} }
if _, err := service.GetParticipation(ctx, token); err != nil { if _, err := service.GetParticipation(ctx, token); err != nil {
@@ -193,7 +193,7 @@ func TestParticipationPostgres(t *testing.T) {
err := <-answers err := <-answers
if err == nil { if err == nil {
success++ success++
} else if errors.Is(err, teams_repo.ErrParticipationFinal) { } else if errors.Is(err, ErrParticipationFinal) {
conflict++ conflict++
} else { } else {
t.Fatal(err) t.Fatal(err)
@@ -239,11 +239,11 @@ func TestParticipationPostgres(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
exec(`UPDATE games SET status='run' WHERE id=1`) exec(`UPDATE games SET status='run' WHERE id=1`)
if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) { if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, ErrParticipationClosed) {
t.Fatalf("running game accepted answer: %v", err) 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`) exec(`UPDATE games SET status='draft',start_at=(NOW() AT TIME ZONE 'UTC')-INTERVAL '1 second' WHERE id=1`)
if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, teams_repo.ErrParticipationClosed) { if _, err := service.AnswerParticipation(ctx, token6, "confirmed"); !errors.Is(err, ErrParticipationClosed) {
t.Fatalf("past scheduled start accepted answer: %v", err) t.Fatalf("past scheduled start accepted answer: %v", err)
} }
state(6, "pending") state(6, "pending")
@@ -28,11 +28,13 @@ func NewParticipationService(teamsRepo *teams_repo.TeamsRepo, appBaseURL string)
return &ParticipationService{teamsRepo: teamsRepo, appBaseURL: appBaseURL} return &ParticipationService{teamsRepo: teamsRepo, appBaseURL: appBaseURL}
} }
func (s *ParticipationService) GetParticipation(ctx context.Context, token string) (*teams_repo.Participation, error) { func (s *ParticipationService) GetParticipation(ctx context.Context, token string) (*Participation, error) {
return s.teamsRepo.GetParticipation(ctx, token) p, err := s.teamsRepo.GetParticipation(ctx, token)
return mapParticipation(p), err
} }
func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*teams_repo.Participation, error) { func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) {
return s.teamsRepo.AnswerParticipation(ctx, token, answer) p, err := s.teamsRepo.AnswerParticipation(ctx, token, answer)
return mapParticipation(p), err
} }
// Стабильные токены для разных целей сохраняют работоспособность ссылок при // Стабильные токены для разных целей сохраняют работоспособность ссылок при
@@ -45,7 +47,7 @@ func participationToken(secret string, id int) string {
return hex.EncodeToString(mac.Sum(nil)) return hex.EncodeToString(mac.Sum(nil))
} }
func participationEmail(p *teams_repo.Participation, baseURL, token string, location *time.Location) email_sender.Message { func participationEmail(p *Participation, baseURL, token string, location *time.Location) email_sender.Message {
link := strings.TrimRight(baseURL, "/") + "/participation#token=" + token link := strings.TrimRight(baseURL, "/") + "/participation#token=" + token
date := p.StartAt.In(location).Format("02.01.2006 в 15:04 (MST, UTC-07:00)") date := p.StartAt.In(location).Format("02.01.2006 в 15:04 (MST, UTC-07:00)")
body := fmt.Sprintf("Команда «%s», подтвердите участие в игре «%s».\nДата: %s\nУчастников: %d\n\nПодтвердить участие: %s&answer=confirmed\nОтменить участие: %s&answer=cancelled\n\nОтвет можно дать до начала игры. Решение окончательное: изменить его нельзя. Без ответа запись сохраняется со статусом «Ожидает ответа».\nНе пересылайте письмо: ссылка позволяет принять решение за команду.", p.TeamName, p.GameName, date, p.ParticipantsCount, link, link) body := fmt.Sprintf("Команда «%s», подтвердите участие в игре «%s».\nДата: %s\nУчастников: %d\n\nПодтвердить участие: %s&answer=confirmed\nОтменить участие: %s&answer=cancelled\n\nОтвет можно дать до начала игры. Решение окончательное: изменить его нельзя. Без ответа запись сохраняется со статусом «Ожидает ответа».\nНе пересылайте письмо: ссылка позволяет принять решение за команду.", p.TeamName, p.GameName, date, p.ParticipantsCount, link, link)
@@ -89,7 +91,7 @@ func (s *ParticipationService) RunParticipationEmails(ctx context.Context, sende
func (s *ParticipationService) 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 { for ctx.Err() == nil {
jobCtx, cancel := context.WithTimeout(ctx, time.Minute) jobCtx, cancel := context.WithTimeout(ctx, time.Minute)
p, err := s.teamsRepo.ClaimParticipationEmail(jobCtx) repoParticipation, err := s.teamsRepo.ClaimParticipationEmail(jobCtx)
if errors.Is(err, pgx.ErrNoRows) { if errors.Is(err, pgx.ErrNoRows) {
cancel() cancel()
return nil return nil
@@ -98,6 +100,7 @@ func (s *ParticipationService) sendParticipationEmails(ctx context.Context, send
cancel() cancel()
return err return err
} }
p := mapParticipation(repoParticipation)
token := participationToken(secret, p.TeamID) token := participationToken(secret, p.TeamID)
err = s.teamsRepo.SetParticipationToken(jobCtx, p.TeamID, token) err = s.teamsRepo.SetParticipationToken(jobCtx, p.TeamID, token)
if err == nil { if err == nil {