This commit is contained in:
2026-10-03 13:07:50 +07:00
parent 7a7808b4d1
commit 3aeaa09ea8
14 changed files with 98 additions and 28 deletions
+1 -1
View File
@@ -111,7 +111,7 @@ func main() {
processorJWT := processor_jwt.NewProcessor(os.Getenv("JWT_SECRET"))
refreshTokensRepo := refresh_tokens_repo.NewRefreshTokensRepo(dbpool)
usersService := users_service.NewUsersService(usersRepo, passwordGenerator, emailSender, processorJWT, refreshTokensRepo, os.Getenv("APP_BASE_URL"))
uiService := ui_service.NewUiService()
uiService := ui_service.NewUIService()
fileStorage, err := file_storage.NewRustFSStorage(
context.Background(),
os.Getenv("S3_HOST"),
+3 -2
View File
@@ -21,7 +21,7 @@ type server struct {
proto.UnsafeEveningDetectiveServerServer
usersService *users_service.UsersService
uiService *ui_service.UiService
uiService *ui_service.UIService
fileService *file_service.FileService
scenarioService *scenarios_service.ScenarioService
gameService *game_service.GameService
@@ -35,9 +35,10 @@ type server struct {
mapService *map_service.MapService
}
// NewServer creates the application server.
func NewServer(
usersService *users_service.UsersService,
uiService *ui_service.UiService,
uiService *ui_service.UIService,
fileService *file_service.FileService,
scenarioService *scenarios_service.ScenarioService,
gameService *game_service.GameService,
+1
View File
@@ -1,6 +1,7 @@
// Package cleaner normalizes user-provided text and codes.
package cleaner
// ICleaner cleans user-provided text and codes.
type ICleaner interface {
ClearCode(code string) string
ClearText(text string) string
+1
View File
@@ -26,6 +26,7 @@ var (
type service struct{}
// NewCleaner creates a text cleaner.
func NewCleaner() ICleaner {
return &service{}
}
+1
View File
@@ -2,6 +2,7 @@ package repos
import "time"
// Scenario describes a stored scenario.
type Scenario struct {
ID int
Name string
+8
View File
@@ -11,19 +11,23 @@ import (
)
var (
// ErrScenarioNotFound indicates that a scenario was not found.
ErrScenarioNotFound = errors.New("Сценарий не найден")
)
// ScenariosRepo persists scenarios.
type ScenariosRepo struct {
pool *pgxpool.Pool
}
// NewScenariosRepo creates a scenarios repository.
func NewScenariosRepo(pool *pgxpool.Pool) *ScenariosRepo {
return &ScenariosRepo{
pool: pool,
}
}
// AddScenario stores a scenario.
func (r *ScenariosRepo) AddScenario(
ctx context.Context,
name string,
@@ -45,6 +49,7 @@ func (r *ScenariosRepo) AddScenario(
return id, nil
}
// GetScenariosByAuthorID returns scenarios created by an author.
func (r *ScenariosRepo) GetScenariosByAuthorID(
ctx context.Context,
authorID int,
@@ -107,6 +112,7 @@ func (r *ScenariosRepo) GetScenariosByAuthorID(
return scenarios, nil
}
// GetScenariosByStatus returns scenarios with the requested status.
func (r *ScenariosRepo) GetScenariosByStatus(
ctx context.Context,
status string,
@@ -169,6 +175,7 @@ func (r *ScenariosRepo) GetScenariosByStatus(
return scenarios, nil
}
// GetScenarioByID returns a scenario by ID.
func (r *ScenariosRepo) GetScenarioByID(
ctx context.Context,
id int,
@@ -313,6 +320,7 @@ func (r *ScenariosRepo) DeleteScenarioByID(
return tag.RowsAffected() > 0, nil
}
// GetStoryByScenarioID returns the story JSON for a scenario.
func (r *ScenariosRepo) GetStoryByScenarioID(
ctx context.Context,
id int,
@@ -109,8 +109,6 @@ func (r *TeamsRepo) AnswerParticipation(ctx context.Context, token, answer strin
return p, ErrParticipationClosed
}
// Пятиминутная аренда переживает перезапуски и не позволяет рабочим процессам
// отправить одну задачу повторно.
// ClaimParticipationEmail claims the next participation email job.
func (r *TeamsRepo) ClaimParticipationEmail(ctx context.Context) (*Participation, error) {
p := &Participation{}
+11 -2
View File
@@ -10,11 +10,15 @@ import (
)
var (
// ErrTicketNotFound indicates that a ticket was not found.
ErrTicketNotFound = errors.New("Билет не найден")
ErrTicketUsed = errors.New("Билет уже использован")
ErrGameFinished = errors.New("Игра уже завершена")
// ErrTicketUsed indicates that a ticket was already used.
ErrTicketUsed = errors.New("Билет уже использован")
// ErrGameFinished indicates that the game has finished.
ErrGameFinished = errors.New("Игра уже завершена")
)
// Info describes a support ticket.
type Info struct {
ID int
Status string
@@ -26,14 +30,17 @@ type Info struct {
UsedBy *string
}
// Repo persists support tickets.
type Repo struct {
pool *pgxpool.Pool
}
// New creates a tickets repository.
func New(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
// CreateBatch creates tickets for a game.
func (r *Repo) CreateBatch(ctx context.Context, gameID int, tokenHashes [][]byte) ([]int, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
@@ -58,6 +65,7 @@ func (r *Repo) CreateBatch(ctx context.Context, gameID int, tokenHashes [][]byte
return ids, nil
}
// GetByHash returns a ticket by token hash.
func (r *Repo) GetByHash(ctx context.Context, tokenHash []byte) (*Info, error) {
info := &Info{}
err := r.pool.QueryRow(ctx, `
@@ -74,6 +82,7 @@ func (r *Repo) GetByHash(ctx context.Context, tokenHash []byte) (*Info, error) {
return info, err
}
// Accept assigns a ticket to a user.
func (r *Repo) Accept(ctx context.Context, tokenHash []byte, userID int) (*Info, error) {
var status, gameStatus string
if err := r.pool.QueryRow(ctx, `
@@ -7,6 +7,7 @@ import (
"evening_detective_server/internal/repos/game_reviews_repo"
)
// Review describes a game review exposed by the service layer.
type Review struct {
ID int
GameID int
@@ -18,15 +18,23 @@ import (
)
var (
ErrTeamNotFound = errors.New("Команда не найдена")
ErrGameNotFound = errors.New("Игра не найдена")
ErrReviewNotFound = errors.New("Отзыв не найден")
ErrReviewExists = errors.New("Отзыв от команды уже оставлен")
ErrInvalidStatus = errors.New("Недопустимый статус модерации")
// ErrTeamNotFound indicates that a team was not found.
ErrTeamNotFound = errors.New("Команда не найдена")
// ErrGameNotFound indicates that a game was not found.
ErrGameNotFound = errors.New("Игра не найдена")
// ErrReviewNotFound indicates that a review was not found.
ErrReviewNotFound = errors.New("Отзыв не найден")
// ErrReviewExists indicates that a team already submitted a review.
ErrReviewExists = errors.New("Отзыв от команды уже оставлен")
// ErrInvalidStatus indicates that a moderation status is invalid.
ErrInvalidStatus = errors.New("Недопустимый статус модерации")
// ErrGameNotFinished indicates that the game is not finished.
ErrGameNotFinished = errors.New("Оценить можно только завершённую игру")
ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен")
// ErrInvalidReview indicates that review data is invalid.
ErrInvalidReview = errors.New("Оценка должна быть от 1 до 5, текст обязателен")
)
// GameReviewService handles game reviews.
type GameReviewService struct {
reviews *game_reviews_repo.Repo
games *games_repo.GamesRepo
@@ -34,11 +42,13 @@ type GameReviewService struct {
appBaseURL string
}
// NewGameReviewService creates a game review service.
// NewGameReviewService creates a game review service.
func NewGameReviewService(reviews *game_reviews_repo.Repo, games *games_repo.GamesRepo, teams *teams_repo.TeamsRepo, appBaseURL string) *GameReviewService {
return &GameReviewService{reviews: reviews, games: games, teams: teams, appBaseURL: appBaseURL}
}
// Create stores a game review.
func (s *GameReviewService) Create(ctx context.Context, gameID, teamID int, password string, rating int32, text string) (*Review, error) {
if rating < 1 || rating > 5 || strings.TrimSpace(text) == "" {
return nil, ErrInvalidReview
@@ -73,6 +83,7 @@ func (s *GameReviewService) Create(ctx context.Context, gameID, teamID int, pass
return mapReview(review), nil
}
// List returns reviews for a game.
func (s *GameReviewService) List(ctx context.Context, gameID int, all bool) ([]*Review, float64, int32, error) {
if _, err := s.games.GetGameByID(ctx, gameID); err != nil {
if errors.Is(err, games_repo.ErrGameNotFound) {
@@ -84,11 +95,13 @@ func (s *GameReviewService) List(ctx context.Context, gameID int, all bool) ([]*
return mapReviews(reviews), average, count, err
}
// ListForModeration returns reviews awaiting moderation.
func (s *GameReviewService) ListForModeration(ctx context.Context) ([]*Review, error) {
reviews, err := s.reviews.ListForModeration(ctx)
return mapReviews(reviews), err
}
// Moderate updates a review moderation status.
func (s *GameReviewService) Moderate(ctx context.Context, id int, status string) (*Review, error) {
review, err := s.reviews.Moderate(ctx, id, status)
if err != nil {
@@ -103,10 +116,12 @@ func (s *GameReviewService) Moderate(ctx context.Context, id int, status string)
return mapReview(review), nil
}
// ScenarioRating returns aggregate ratings for a scenario.
func (s *GameReviewService) ScenarioRating(ctx context.Context, scenarioID int) (float64, int32, error) {
return s.reviews.ScenarioRating(ctx, scenarioID)
}
// ListByScenario returns reviews for a scenario.
func (s *GameReviewService) ListByScenario(ctx context.Context, scenarioID int) ([]*Review, float64, int32, error) {
reviews, average, count, err := s.reviews.ListByScenario(ctx, scenarioID)
return mapReviews(reviews), average, count, err
@@ -121,6 +136,7 @@ func reviewReminderEmail(job *teams_repo.ReviewReminder, baseURL string) email_s
return email_sender.Message{To: job.Email, Subject: "Поделитесь впечатлениями об игре", Body: body, HTML: email_sender.WrapHTML(content, strings.TrimRight(baseURL, "/"))}
}
// RunReviewReminderEmails sends pending review reminder emails.
func (s *GameReviewService) RunReviewReminderEmails(ctx context.Context, sender email_sender.IEmailSender) {
base, err := url.Parse(s.appBaseURL)
if err != nil || base.Host == "" || (base.Scheme != "https" && base.Scheme != "http") || base.User != nil || base.RawQuery != "" || base.Fragment != "" {
+16 -4
View File
@@ -18,18 +18,25 @@ import (
const maxTicketsPerRequest = 100
var (
// ErrInvalidTicketCount indicates that the requested ticket count is invalid.
ErrInvalidTicketCount = errors.New("количество билетов должно быть от 1 до 100")
ErrInvalidBaseURL = errors.New("необходимо настроить APP_BASE_URL — публичный адрес приложения")
ErrTicketNotFound = errors.New("Билет не найден")
ErrTicketUsed = errors.New("Билет уже использован")
ErrGameFinished = errors.New("Игра уже завершена")
// ErrInvalidBaseURL indicates that the public application URL is missing.
ErrInvalidBaseURL = errors.New("необходимо настроить APP_BASE_URL — публичный адрес приложения")
// ErrTicketNotFound indicates that a ticket was not found.
ErrTicketNotFound = errors.New("Билет не найден")
// ErrTicketUsed indicates that a ticket was already used.
ErrTicketUsed = errors.New("Билет уже использован")
// ErrGameFinished indicates that the game has finished.
ErrGameFinished = errors.New("Игра уже завершена")
)
// Ticket describes an issued support ticket.
type Ticket struct {
ID int
URL string
}
// Page contains tickets returned for a user.
type Page struct {
ID int
Status string
@@ -41,15 +48,18 @@ type Page struct {
UsedBy *string
}
// TicketService handles support tickets.
type TicketService struct {
repo *tickets_repo.Repo
appBaseURL string
}
// NewTicketService creates a ticket service.
func NewTicketService(repo *tickets_repo.Repo, appBaseURL string) *TicketService {
return &TicketService{repo: repo, appBaseURL: appBaseURL}
}
// Issue creates tickets for a game.
func (s *TicketService) Issue(ctx context.Context, gameID, count int) ([]*Ticket, error) {
if count < 1 || count > maxTicketsPerRequest {
return nil, ErrInvalidTicketCount
@@ -87,6 +97,7 @@ func (s *TicketService) Issue(ctx context.Context, gameID, count int) ([]*Ticket
return tickets, nil
}
// Get returns tickets associated with a token.
func (s *TicketService) Get(ctx context.Context, token string) (*Page, error) {
hash, err := tokenHash(token)
if err != nil {
@@ -108,6 +119,7 @@ func (s *TicketService) Get(ctx context.Context, token string) (*Page, error) {
}, nil
}
// Accept assigns a ticket to a user.
func (s *TicketService) Accept(ctx context.Context, token string, userID int) (*Page, error) {
hash, err := tokenHash(token)
if err != nil {
+8 -5
View File
@@ -28,13 +28,16 @@ var (
}
)
type UiService struct{}
// UIService provides user interface permissions.
type UIService struct{}
func NewUiService() *UiService {
return &UiService{}
// NewUIService creates a UI service.
func NewUIService() *UIService {
return &UIService{}
}
func (s *UiService) GetPermissions(roles []string) []string {
// GetPermissions returns permissions for the supplied roles.
func (s *UIService) GetPermissions(roles []string) []string {
resMap := s.getPermissionsMap(roles)
res := make([]string, 0, len(resMap))
for permission := range resMap {
@@ -43,7 +46,7 @@ func (s *UiService) GetPermissions(roles []string) []string {
return res
}
func (s *UiService) getPermissionsMap(roles []string) map[string]any {
func (s *UIService) getPermissionsMap(roles []string) map[string]any {
resMap := make(map[string]any, 100)
if slices.Contains(roles, rolesModule.Admin) {
+24 -6
View File
@@ -53,6 +53,7 @@ type usersRepository interface {
DeleteUser(ctx context.Context, userID int) error
}
// UsersService handles user accounts and authentication.
type UsersService struct {
usersRepo usersRepository
passwordGenerator password_generator.IPasswordGenerator
@@ -64,6 +65,7 @@ type UsersService struct {
appBaseURL string
}
// NewUsersService creates a users service.
func NewUsersService(
usersRepo usersRepository,
passwordGenerator password_generator.IPasswordGenerator,
@@ -84,14 +86,20 @@ func NewUsersService(
var (
// ErrTermsNotAccepted — регистрация невозможна без акцепта соглашений.
ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
ErrInvalidEmail = errors.New("Некорректный email")
ErrInvalidCredentials = errors.New("Не верен email или пароль")
ErrTermsNotAccepted = errors.New("Необходимо принять Пользовательское соглашение и Политику конфиденциальности")
// ErrInvalidEmail indicates that an email address is invalid.
ErrInvalidEmail = errors.New("Некорректный email")
// ErrInvalidCredentials indicates that the supplied credentials are invalid.
ErrInvalidCredentials = errors.New("Не верен email или пароль")
// ErrInvalidAccountPassword indicates that the account password is invalid.
ErrInvalidAccountPassword = errors.New("Неверный пароль")
ErrInvalidRefreshToken = errors.New("Пользователь не найден")
ErrInvalidRole = errors.New("Недопустимая роль")
// ErrInvalidRefreshToken indicates that a refresh token is invalid.
ErrInvalidRefreshToken = errors.New("Пользователь не найден")
// ErrInvalidRole indicates that a role is invalid.
ErrInvalidRole = errors.New("Недопустимая роль")
)
// AddUser creates a user account.
func (s *UsersService) AddUser(
ctx context.Context,
username string,
@@ -192,6 +200,7 @@ func (s *UsersService) DeleteAccount(
return err
}
// RefreshPassword generates and sends a new password.
func (s *UsersService) RefreshPassword(
ctx context.Context,
email string,
@@ -252,6 +261,7 @@ func validateEmail(email string) error {
return nil
}
// Login authenticates a user and returns tokens.
func (s *UsersService) Login(
ctx context.Context,
email string,
@@ -294,6 +304,7 @@ func (s *UsersService) Login(
return accessToken, refreshToken, nil
}
// Refresh exchanges a refresh token for new tokens.
func (s *UsersService) Refresh(
ctx context.Context,
token string,
@@ -344,6 +355,7 @@ func (s *UsersService) Refresh(
return accessToken, refreshToken, nil
}
// GetUsers returns users.
func (s *UsersService) GetUsers(
ctx context.Context,
) ([]*User, error) {
@@ -354,6 +366,7 @@ func (s *UsersService) GetUsers(
return mapUsers(users), nil
}
// GetUserByID returns a user by ID.
func (s *UsersService) GetUserByID(
ctx context.Context,
userID int,
@@ -368,6 +381,7 @@ func (s *UsersService) GetUserByID(
return mapUser(user), nil
}
// AddUserRole assigns a role to a user.
func (s *UsersService) AddUserRole(
ctx context.Context,
userID int,
@@ -383,6 +397,7 @@ func (s *UsersService) AddUserRole(
return err
}
// DeleteUserRole removes a role from a user.
func (s *UsersService) DeleteUserRole(
ctx context.Context,
userID int,
@@ -399,13 +414,16 @@ func (s *UsersService) DeleteUserRole(
}
var (
// ErrPasswordChangeForbidden indicates that the password cannot be changed.
ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор")
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов")
// ErrInvalidPasswordChange indicates that the password change request is invalid.
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 8 до 16 символов")
// ErrUserNotFound — целевой пользователь не найден.
ErrUserNotFound = errors.New("Пользователь не найден")
)
// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются.
// ChangeUserPassword changes a user's password subject to authorization checks.
func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error {
actor, err := s.usersRepo.GetUserByID(ctx, actorID)
if errors.Is(err, users_repo.ErrUserNotFound) {
+1
View File
@@ -3,6 +3,7 @@ package users_service
import "time"
// User — пользователь, доступный прикладным сценариям и выдаче наружу.
// User describes a user exposed by the service layer.
type User struct {
ID int
Username string