Files
evening_detective_server/internal/services/game_service/participation.go
T
2026-09-16 02:44:44 +07:00

105 lines
5.1 KiB
Go

package game_service
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"html"
"log"
"net/url"
"strings"
"time"
_ "time/tzdata"
"evening_detective_server/internal/modules/email_sender"
"evening_detective_server/internal/repos/teams_repo"
"github.com/jackc/pgx/v5"
)
func (s *GameService) 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) {
return s.teamsRepo.AnswerParticipation(ctx, token, answer)
}
// Stable, purpose-separated tokens keep links valid when an ambiguous SMTP failure is retried.
// Only the hash is stored in the database; the signing secret must stay stable across workers/restarts.
func participationToken(secret string, id int) string {
mac := hmac.New(sha256.New, []byte(secret))
fmt.Fprintf(mac, "team-participation:v1:%d", id)
return hex.EncodeToString(mac.Sum(nil))
}
func participationEmail(p *teams_repo.Participation, baseURL, token string, location *time.Location) email_sender.Message {
link := strings.TrimRight(baseURL, "/") + "/participation#token=" + token
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)
markup := "<!doctype html><html lang=\"ru\"><meta charset=\"utf-8\"><body><h2>Подтвердите участие в игре</h2><p>" + html.EscapeString(p.TeamName) + " — «" + html.EscapeString(p.GameName) + "»</p><p>" + html.EscapeString(date) + fmt.Sprintf(" · Участников: %d</p>", p.ParticipantsCount)
for _, action := range []struct{ value, label string }{{"confirmed", "Подтвердить участие"}, {"cancelled", "Отменить участие"}} {
markup += `<p><a href="` + html.EscapeString(link+"&answer="+action.value) + `" style="display:inline-block;padding:12px 20px;background:#342a22;color:#fff;border-radius:5px">` + action.label + `</a></p>`
}
markup += "<p>Ответ можно дать до начала игры. <strong>Решение окончательное: изменить его нельзя.</strong></p><p>Без ответа запись сохраняется со статусом «Ожидает ответа».</p><p>Не пересылайте письмо: ссылка позволяет принять решение за команду.</p></body></html>"
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) {
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")
return
}
if timezone == "" {
timezone = "Asia/Barnaul"
}
location, err := time.LoadLocation(timezone)
if err != nil {
log.Printf("participation email worker disabled: invalid GAME_TIMEZONE: %v", err)
return
}
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
if err := s.sendParticipationEmails(ctx, sender, secret, location); err != nil && ctx.Err() == nil {
log.Printf("participation email worker: %v", err)
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (s *GameService) 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)
if errors.Is(err, pgx.ErrNoRows) {
cancel()
return nil
}
if err != nil {
cancel()
return err
}
token := participationToken(secret, p.TeamID)
err = s.teamsRepo.SetParticipationToken(jobCtx, p.TeamID, token)
if err == nil {
err = sender.Send(jobCtx, participationEmail(p, s.appBaseURL, token, location))
}
cancel()
// Persist even when sending consumed the job's deadline.
saveCtx, saveCancel := context.WithTimeout(ctx, 10*time.Second)
saveErr := s.teamsRepo.FinishParticipationEmail(saveCtx, p.TeamID, p.Attempts, err)
saveCancel()
if saveErr != nil {
return saveErr
}
}
return ctx.Err()
}