add tickets api

This commit is contained in:
2026-09-18 16:08:35 +07:00
parent 32191529a5
commit 1be16d706e
11 changed files with 778 additions and 185 deletions
+4
View File
@@ -11,6 +11,7 @@ import (
"evening_detective_server/internal/services/participation_service"
"evening_detective_server/internal/services/scenarios_service"
"evening_detective_server/internal/services/team_service"
"evening_detective_server/internal/services/ticket_service"
"evening_detective_server/internal/services/ui_service"
"evening_detective_server/internal/services/users_service"
proto "evening_detective_server/proto"
@@ -28,6 +29,7 @@ type server struct {
answerService *answer_service.AnswerService
reviewService *game_review_service.Service
teamService *team_service.TeamService
ticketService *ticket_service.Service
participationService *participation_service.ParticipationService
screenRepo *screen_sessions_repo.ScreenSessionsRepo
mapRepo *game_map_repo.Repo
@@ -43,6 +45,7 @@ func NewServer(
answerService *answer_service.AnswerService,
reviewService *game_review_service.Service,
teamService *team_service.TeamService,
ticketService *ticket_service.Service,
participationService *participation_service.ParticipationService,
screenRepo *screen_sessions_repo.ScreenSessionsRepo,
mapRepo *game_map_repo.Repo,
@@ -57,6 +60,7 @@ func NewServer(
answerService: answerService,
reviewService: reviewService,
teamService: teamService,
ticketService: ticketService,
participationService: participationService,
screenRepo: screenRepo,
mapRepo: mapRepo,
+35
View File
@@ -0,0 +1,35 @@
package app
import (
"context"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/modules/roles"
"evening_detective_server/proto"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func (s *server) IssueGameTickets(ctx context.Context, req *proto.IssueGameTicketsReq) (*proto.IssueGameTicketsRsp, error) {
claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims)
if !ok || claims == nil {
return nil, status.Error(codes.Unauthenticated, "необходима авторизация")
}
if !roles.HasRole(claims, roles.Organizer) && !roles.HasRole(claims, roles.Author) {
return nil, status.Error(codes.PermissionDenied, "permission denied")
}
if req == nil || req.GameId <= 0 {
return nil, status.Error(codes.InvalidArgument, "некорректный идентификатор игры")
}
tickets, err := s.ticketService.Issue(ctx, int(req.GameId), int(req.Count))
if err != nil {
return &proto.IssueGameTicketsRsp{Error: err.Error()}, nil
}
result := make([]*proto.Ticket, 0, len(tickets))
for _, ticket := range tickets {
result = append(result, &proto.Ticket{Id: int32(ticket.ID), Url: ticket.URL})
}
return &proto.IssueGameTicketsRsp{Tickets: result}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package tickets_repo
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repo struct {
pool *pgxpool.Pool
}
func New(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
func (r *Repo) CreateBatch(ctx context.Context, gameID int, tokenHashes [][]byte) ([]int, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
ids := make([]int, 0, len(tokenHashes))
for _, tokenHash := range tokenHashes {
var id int
if err := tx.QueryRow(ctx,
`INSERT INTO tickets (game_id, token_hash) VALUES ($1, $2) RETURNING id`,
gameID, tokenHash,
).Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return ids, nil
}
@@ -0,0 +1,88 @@
package ticket_service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/url"
"strings"
"evening_detective_server/internal/repos/tickets_repo"
)
const maxTicketsPerRequest = 100
var (
ErrInvalidTicketCount = errors.New("количество билетов должно быть от 1 до 100")
ErrInvalidBaseURL = errors.New("необходимо настроить APP_BASE_URL — публичный адрес приложения")
)
type Ticket struct {
ID int
URL string
}
type Service struct {
repo *tickets_repo.Repo
appBaseURL string
}
func New(repo *tickets_repo.Repo, appBaseURL string) *Service {
return &Service{repo: repo, appBaseURL: appBaseURL}
}
func (s *Service) Issue(ctx context.Context, gameID, count int) ([]*Ticket, error) {
if count < 1 || count > maxTicketsPerRequest {
return nil, ErrInvalidTicketCount
}
base, err := parseBaseURL(s.appBaseURL)
if err != nil {
return nil, err
}
hashes := make([][]byte, count)
tokens := make([]string, count)
for i := range count {
token, err := newToken()
if err != nil {
return nil, err
}
hash := sha256.Sum256([]byte(token))
tokens[i] = token
hashes[i] = hash[:]
}
ids, err := s.repo.CreateBatch(ctx, gameID, hashes)
if err != nil {
return nil, err
}
tickets := make([]*Ticket, count)
for i, id := range ids {
link := *base
link.Path = strings.TrimRight(base.Path, "/") + "/tickets/" + tokens[i]
link.RawPath = ""
tickets[i] = &Ticket{ID: id, URL: link.String()}
}
return tickets, nil
}
func newToken() (string, error) {
data := make([]byte, 32)
if _, err := rand.Read(data); err != nil {
return "", fmt.Errorf("generate ticket token: %w", err)
}
return base64.RawURLEncoding.EncodeToString(data), nil
}
func parseBaseURL(value string) (*url.URL, error) {
base, err := url.Parse(strings.TrimSpace(value))
if err != nil || base.Host == "" || (base.Scheme != "http" && base.Scheme != "https") || base.User != nil || base.RawQuery != "" || base.Fragment != "" {
return nil, ErrInvalidBaseURL
}
return base, nil
}
@@ -0,0 +1,17 @@
package ticket_service
import "testing"
func TestNewToken(t *testing.T) {
first, err := newToken()
if err != nil {
t.Fatal(err)
}
second, err := newToken()
if err != nil {
t.Fatal(err)
}
if first == second || len(first) != 43 || len(second) != 43 {
t.Fatalf("invalid tokens: %q %q", first, second)
}
}