Files
evening_detective_server/internal/services/participation_service/service.go
T
2026-10-02 01:06:19 +07:00

120 lines
5.8 KiB
Go

package participation_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"
)
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) (*Participation, error) {
p, err := s.teamsRepo.GetParticipation(ctx, token)
return mapParticipation(p), err
}
func (s *ParticipationService) AnswerParticipation(ctx context.Context, token, answer string) (*Participation, error) {
p, err := s.teamsRepo.AnswerParticipation(ctx, token, answer)
return mapParticipation(p), err
}
// Стабильные токены для разных целей сохраняют работоспособность ссылок при
// повторе после неоднозначной ошибки SMTP.
// В базе хранится только хеш; секрет подписи должен быть постоянным между
// рабочими процессами и перезапусками.
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 *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)
content := "<p class=\"email-text\"><strong>Подтвердите участие в игре</strong></p><p class=\"email-text\">" + html.EscapeString(p.TeamName) + " — «" + html.EscapeString(p.GameName) + "»</p><p class=\"email-text\">" + html.EscapeString(date) + fmt.Sprintf(" · Участников: %d</p>", p.ParticipantsCount)
for _, action := range []struct{ value, label string }{{"confirmed", "Подтвердить участие"}, {"cancelled", "Отменить участие"}} {
content += email_sender.ButtonHTML(link+"&answer="+action.value, action.label)
}
content += "<p class=\"email-text\">Ответ можно дать до начала игры. <strong>Решение окончательное: изменить его нельзя.</strong></p><p class=\"email-text\">Без ответа запись сохраняется со статусом «Ожидает ответа».</p><p class=\"email-text\">Не пересылайте письмо: ссылка позволяет принять решение за команду.</p>"
markup := email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/"))
return email_sender.Message{To: p.Email, Subject: "Подтвердите участие в «Вечернем детективе»", Body: body, HTML: markup}
}
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")
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 *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)
repoParticipation, err := s.teamsRepo.ClaimParticipationEmail(jobCtx)
if errors.Is(err, pgx.ErrNoRows) {
cancel()
return nil
}
if err != nil {
cancel()
return err
}
p := mapParticipation(repoParticipation)
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()
// Сохраняем результат даже если отправка исчерпала время задачи.
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()
}