initial commit

This commit is contained in:
2025-11-29 00:28:21 +05:00
parent 46229acc82
commit ec3b03a935
76 changed files with 13492 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/pkg/jwt"
"git.kirlllll.ru/volontery/backend/internal/pkg/password"
"git.kirlllll.ru/volontery/backend/internal/repository"
)
// AuthService предоставляет методы для аутентификации
type AuthService struct {
userRepo *repository.UserRepository
authRepo *repository.AuthRepository
rbacRepo *repository.RBACRepository
jwtMgr *jwt.Manager
}
// NewAuthService создает новый AuthService
func NewAuthService(
userRepo *repository.UserRepository,
authRepo *repository.AuthRepository,
rbacRepo *repository.RBACRepository,
jwtMgr *jwt.Manager,
) *AuthService {
return &AuthService{
userRepo: userRepo,
authRepo: authRepo,
rbacRepo: rbacRepo,
jwtMgr: jwtMgr,
}
}
// RegisterRequest - запрос на регистрацию
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Phone string `json:"phone,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
Bio string `json:"bio,omitempty"`
}
// LoginRequest - запрос на вход
type LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// AuthResponse - ответ с токенами
type AuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
User *UserInfo `json:"user"`
}
// UserInfo - информация о пользователе
type UserInfo struct {
ID int64 `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Verified bool `json:"email_verified"`
}
// Register регистрирует нового пользователя
func (s *AuthService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) {
// Валидация
if req.Email == "" {
return nil, fmt.Errorf("email is required")
}
if !password.IsValid(req.Password) {
return nil, fmt.Errorf("password must be at least 8 characters")
}
if req.FirstName == "" || req.LastName == "" {
return nil, fmt.Errorf("first name and last name are required")
}
// Проверка существования email
exists, err := s.userRepo.EmailExists(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("failed to check email: %w", err)
}
if exists {
return nil, fmt.Errorf("email already registered")
}
// Хеширование пароля
hashedPassword, err := password.Hash(req.Password)
if err != nil {
return nil, fmt.Errorf("failed to hash password: %w", err)
}
// Создание пользователя
user, err := s.userRepo.Create(ctx, database.CreateUserParams{
Email: req.Email,
PasswordHash: hashedPassword,
FirstName: req.FirstName,
LastName: req.LastName,
Phone: stringToPgText(req.Phone),
StMakepoint: req.Longitude,
StMakepoint_2: req.Latitude,
Address: stringToPgText(req.Address),
City: stringToPgText(req.City),
})
if err != nil {
return nil, fmt.Errorf("failed to create user: %w", err)
}
// Назначение роли "requester" по умолчанию
requesterRole, err := s.rbacRepo.GetRoleByName(ctx, "requester")
if err == nil {
_, _ = s.rbacRepo.AssignRoleToUser(ctx, database.AssignRoleToUserParams{
UserID: user.ID,
RoleID: requesterRole.ID,
AssignedBy: int64ToPgInt8(user.ID), // сам себе назначил
})
}
// Генерация токенов
return s.generateTokens(ctx, user.ID, user.Email, "", "")
}
// Login выполняет вход пользователя
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
// Получение пользователя
user, err := s.userRepo.GetByEmail(ctx, req.Email)
if err != nil {
return nil, fmt.Errorf("invalid email or password")
}
// Проверка пароля
if err := password.Verify(user.PasswordHash, req.Password); err != nil {
return nil, fmt.Errorf("invalid email or password")
}
// Проверка блокировки
if user.IsBlocked.Bool {
return nil, fmt.Errorf("user account is blocked")
}
// Обновление времени последнего входа
_ = s.userRepo.UpdateLastLogin(ctx, user.ID)
// Генерация токенов
return s.generateTokens(ctx, user.ID, user.Email, "", "")
}
// RefreshTokens обновляет токены
func (s *AuthService) RefreshTokens(ctx context.Context, refreshTokenString string) (*AuthResponse, error) {
// Валидация refresh токена
claims, err := s.jwtMgr.ValidateToken(refreshTokenString)
if err != nil {
return nil, fmt.Errorf("invalid refresh token")
}
// Проверка токена в БД
storedToken, err := s.authRepo.GetRefreshToken(ctx, refreshTokenString)
if err != nil {
return nil, fmt.Errorf("refresh token not found or expired")
}
// Отзыв старого токена
_ = s.authRepo.RevokeRefreshToken(ctx, storedToken.ID)
// Получение пользователя
user, err := s.userRepo.GetByID(ctx, claims.UserID)
if err != nil {
return nil, fmt.Errorf("user not found")
}
if user.IsBlocked.Bool {
return nil, fmt.Errorf("user account is blocked")
}
// Генерация новых токенов
return s.generateTokens(ctx, user.ID, user.Email, "", "")
}
// Logout выход пользователя
func (s *AuthService) Logout(ctx context.Context, userID int64) error {
return s.authRepo.RevokeAllUserTokens(ctx, userID)
}
// generateTokens генерирует access и refresh токены
func (s *AuthService) generateTokens(ctx context.Context, userID int64, email, userAgent, ipAddress string) (*AuthResponse, error) {
// Генерация access токена
accessToken, err := s.jwtMgr.GenerateAccessToken(userID, email)
if err != nil {
return nil, fmt.Errorf("failed to generate access token: %w", err)
}
// Генерация refresh токена
refreshToken, err := s.jwtMgr.GenerateRefreshToken(userID, email)
if err != nil {
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
}
// Сохранение refresh токена в БД
expiresAt := time.Now().Add(s.jwtMgr.GetRefreshTokenDuration())
_, err = s.authRepo.CreateRefreshToken(ctx, database.CreateRefreshTokenParams{
UserID: userID,
Token: refreshToken,
ExpiresAt: timeToPgTimestamptz(expiresAt),
UserAgent: stringToPgText(userAgent),
IpAddress: nil, // IP адрес не передается в текущей реализации
})
if err != nil {
return nil, fmt.Errorf("failed to save refresh token: %w", err)
}
// Получение информации о пользователе
user, _ := s.userRepo.GetByID(ctx, userID)
return &AuthResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int64(s.jwtMgr.GetAccessTokenDuration().Seconds()),
User: &UserInfo{
ID: userID,
Email: email,
FirstName: user.FirstName,
LastName: user.LastName,
Verified: user.EmailVerified.Bool,
},
}, nil
}
// generateRandomToken генерирует случайный токен
func generateRandomToken(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,27 @@
package service
import (
"time"
"github.com/jackc/pgx/v5/pgtype"
)
// Helper functions для конвертации типов Go в pgtype
func stringToPgText(s string) pgtype.Text {
if s == "" {
return pgtype.Text{Valid: false}
}
return pgtype.Text{String: s, Valid: true}
}
func int64ToPgInt8(i int64) pgtype.Int8 {
if i == 0 {
return pgtype.Int8{Valid: false}
}
return pgtype.Int8{Int64: i, Valid: true}
}
func timeToPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}

View File

@@ -0,0 +1,202 @@
package service
import (
"context"
"fmt"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/repository"
"github.com/jackc/pgx/v5/pgtype"
)
// RequestService предоставляет методы для работы с заявками
type RequestService struct {
requestRepo *repository.RequestRepository
}
// NewRequestService создает новый RequestService
func NewRequestService(requestRepo *repository.RequestRepository) *RequestService {
return &RequestService{
requestRepo: requestRepo,
}
}
// CreateRequestInput - входные данные для создания заявки
type CreateRequestInput struct {
RequesterID int64 `json:"requester_id"`
RequestTypeID int64 `json:"request_type_id"`
Title string `json:"title"`
Description string `json:"description"`
Latitude float64 `json:"latitude"`
Longitude float64 `json:"longitude"`
Address string `json:"address"`
City string `json:"city,omitempty"`
DesiredCompletionDate *string `json:"desired_completion_date,omitempty"`
Urgency string `json:"urgency"`
ContactPhone string `json:"contact_phone,omitempty"`
ContactNotes string `json:"contact_notes,omitempty"`
}
// CreateRequest создает новую заявку
func (s *RequestService) CreateRequest(ctx context.Context, input CreateRequestInput) (*database.CreateRequestRow, error) {
// Валидация
if input.Title == "" {
return nil, fmt.Errorf("title is required")
}
if input.Description == "" {
return nil, fmt.Errorf("description is required")
}
if input.Latitude == 0 || input.Longitude == 0 {
return nil, fmt.Errorf("location is required")
}
// Создание заявки
return s.requestRepo.Create(ctx, database.CreateRequestParams{
RequesterID: input.RequesterID,
RequestTypeID: input.RequestTypeID,
Title: input.Title,
Description: input.Description,
StMakepoint: input.Longitude,
StMakepoint_2: input.Latitude,
Address: input.Address,
City: stringToPgText(input.City),
Urgency: stringToPgText(input.Urgency),
ContactPhone: stringToPgText(input.ContactPhone),
ContactNotes: stringToPgText(input.ContactNotes),
})
}
// GetRequest получает заявку по ID
func (s *RequestService) GetRequest(ctx context.Context, id int64) (*database.GetRequestByIDRow, error) {
return s.requestRepo.GetByID(ctx, id)
}
// GetUserRequests получает заявки пользователя
func (s *RequestService) GetUserRequests(ctx context.Context, userID int64, limit, offset int32) ([]database.GetRequestsByRequesterRow, error) {
return s.requestRepo.GetByRequester(ctx, database.GetRequestsByRequesterParams{
RequesterID: userID,
Limit: limit,
Offset: offset,
})
}
// FindNearbyRequests ищет заявки рядом с точкой
func (s *RequestService) FindNearbyRequests(ctx context.Context, lat, lon float64, radiusMeters float64, statuses []database.RequestStatus, limit, offset int32) ([]database.FindRequestsNearbyRow, error) {
// Конвертируем []RequestStatus в []string
statusStrings := make([]string, len(statuses))
for i, status := range statuses {
statusStrings[i] = string(status)
}
return s.requestRepo.FindNearby(ctx, database.FindRequestsNearbyParams{
StMakepoint: lon,
StMakepoint_2: lat,
Column3: statusStrings,
StDwithin: radiusMeters,
Limit: limit,
Offset: offset,
})
}
// FindRequestsInBounds ищет заявки в прямоугольной области (для карты)
func (s *RequestService) FindRequestsInBounds(ctx context.Context, statuses []database.RequestStatus, minLon, minLat, maxLon, maxLat float64) ([]database.FindRequestsInBoundsRow, error) {
// Конвертируем []RequestStatus в []string
statusStrings := make([]string, len(statuses))
for i, status := range statuses {
statusStrings[i] = string(status)
}
return s.requestRepo.FindInBounds(ctx, database.FindRequestsInBoundsParams{
Column1: statusStrings,
StMakeenvelope: minLon,
StMakeenvelope_2: minLat,
StMakeenvelope_3: maxLon,
StMakeenvelope_4: maxLat,
})
}
// CreateVolunteerResponse создает отклик волонтера на заявку
func (s *RequestService) CreateVolunteerResponse(ctx context.Context, requestID, volunteerID int64, message string) (*database.VolunteerResponse, error) {
return s.requestRepo.CreateVolunteerResponse(ctx, database.CreateVolunteerResponseParams{
RequestID: requestID,
VolunteerID: volunteerID,
Message: stringToPgText(message),
})
}
// GetRequestResponses получает отклики на заявку
func (s *RequestService) GetRequestResponses(ctx context.Context, requestID int64) ([]database.GetResponsesByRequestRow, error) {
return s.requestRepo.GetResponsesByRequest(ctx, requestID)
}
// ListRequestTypes получает список типов заявок
func (s *RequestService) ListRequestTypes(ctx context.Context) ([]database.RequestType, error) {
return s.requestRepo.ListTypes(ctx)
}
// GetPendingModerationRequests получает заявки на модерации
func (s *RequestService) GetPendingModerationRequests(ctx context.Context, limit, offset int32) ([]database.GetPendingModerationRequestsRow, error) {
return s.requestRepo.GetPendingModerationRequests(ctx, limit, offset)
}
// ApproveRequest одобряет заявку
func (s *RequestService) ApproveRequest(ctx context.Context, requestID, moderatorID int64, comment *string) error {
moderationComment := stringToPgText("")
if comment != nil {
moderationComment = stringToPgText(*comment)
}
return s.requestRepo.ApproveRequest(ctx, database.ApproveRequestParams{
ID: requestID,
ModeratedBy: pgtype.Int8{
Int64: moderatorID,
Valid: true,
},
ModerationComment: moderationComment,
})
}
// RejectRequest отклоняет заявку
func (s *RequestService) RejectRequest(ctx context.Context, requestID, moderatorID int64, comment string) error {
if comment == "" {
return fmt.Errorf("rejection comment is required")
}
return s.requestRepo.RejectRequest(ctx, database.RejectRequestParams{
ID: requestID,
ModeratedBy: pgtype.Int8{
Int64: moderatorID,
Valid: true,
},
ModerationComment: stringToPgText(comment),
})
}
// GetModeratedRequests получает заявки, модерированные указанным модератором
func (s *RequestService) GetModeratedRequests(ctx context.Context, moderatorID int64, limit, offset int32) ([]database.GetModeratedRequestsRow, error) {
return s.requestRepo.GetModeratedRequests(ctx, moderatorID, limit, offset)
}
// AcceptVolunteerResponse принимает отклик волонтера через хранимую процедуру
func (s *RequestService) AcceptVolunteerResponse(ctx context.Context, responseID, requesterID int64) (*database.CallAcceptVolunteerResponseRow, error) {
return s.requestRepo.AcceptVolunteerResponse(ctx, responseID, requesterID)
}
// CompleteRequestWithRating завершает заявку с рейтингом через хранимую процедуру
func (s *RequestService) CompleteRequestWithRating(ctx context.Context, requestID, requesterID int64, rating int32, comment *string) (*database.CallCompleteRequestWithRatingRow, error) {
if rating < 1 || rating > 5 {
return nil, fmt.Errorf("rating must be between 1 and 5")
}
return s.requestRepo.CompleteRequestWithRating(ctx, requestID, requesterID, rating, comment)
}
// ModerateRequestProcedure модерирует заявку через хранимую процедуру
func (s *RequestService) ModerateRequestProcedure(ctx context.Context, requestID, moderatorID int64, action string, comment *string) (*database.CallModerateRequestRow, error) {
if action != "approve" && action != "reject" {
return nil, fmt.Errorf("action must be 'approve' or 'reject'")
}
if action == "reject" && (comment == nil || *comment == "") {
return nil, fmt.Errorf("comment is required when rejecting")
}
return s.requestRepo.ModerateRequestProcedure(ctx, requestID, moderatorID, action, comment)
}

View File

@@ -0,0 +1,96 @@
package service
import (
"context"
"fmt"
"git.kirlllll.ru/volontery/backend/internal/database"
"git.kirlllll.ru/volontery/backend/internal/repository"
)
// UserService предоставляет методы для работы с пользователями
type UserService struct {
userRepo *repository.UserRepository
rbacRepo *repository.RBACRepository
}
// NewUserService создает новый UserService
func NewUserService(userRepo *repository.UserRepository, rbacRepo *repository.RBACRepository) *UserService {
return &UserService{
userRepo: userRepo,
rbacRepo: rbacRepo,
}
}
// GetUserProfile получает профиль пользователя
func (s *UserService) GetUserProfile(ctx context.Context, userID int64) (*database.GetUserProfileRow, error) {
return s.userRepo.GetProfile(ctx, userID)
}
// UpdateProfileInput - входные данные для обновления профиля
type UpdateProfileInput struct {
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Phone string `json:"phone,omitempty"`
Bio string `json:"bio,omitempty"`
Address string `json:"address,omitempty"`
City string `json:"city,omitempty"`
}
// UpdateUserProfile обновляет профиль пользователя
func (s *UserService) UpdateUserProfile(ctx context.Context, userID int64, input UpdateProfileInput) error {
return s.userRepo.UpdateProfile(ctx, database.UpdateUserProfileParams{
UserID: userID,
FirstName: stringToPgText(input.FirstName),
LastName: stringToPgText(input.LastName),
Phone: stringToPgText(input.Phone),
Address: stringToPgText(input.Address),
City: stringToPgText(input.City),
})
}
// UpdateUserLocation обновляет местоположение пользователя
func (s *UserService) UpdateUserLocation(ctx context.Context, userID int64, lat, lon float64) error {
if lat == 0 || lon == 0 {
return fmt.Errorf("invalid coordinates")
}
return s.userRepo.UpdateLocation(ctx, database.UpdateUserLocationParams{
ID: userID,
StMakepoint: lon,
StMakepoint_2: lat,
})
}
// VerifyEmail подтверждает email пользователя
func (s *UserService) VerifyEmail(ctx context.Context, userID int64) error {
return s.userRepo.VerifyEmail(ctx, userID)
}
// GetUserRoles получает роли пользователя
func (s *UserService) GetUserRoles(ctx context.Context, userID int64) ([]database.Role, error) {
return s.rbacRepo.GetUserRoles(ctx, userID)
}
// GetUserPermissions получает разрешения пользователя
func (s *UserService) GetUserPermissions(ctx context.Context, userID int64) ([]database.GetUserPermissionsRow, error) {
return s.rbacRepo.GetUserPermissions(ctx, userID)
}
// HasPermission проверяет наличие разрешения у пользователя
func (s *UserService) HasPermission(ctx context.Context, userID int64, permissionName string) (bool, error) {
return s.rbacRepo.UserHasPermission(ctx, database.UserHasPermissionParams{
ID: userID,
Name: permissionName,
})
}
// AssignRole назначает роль пользователю
func (s *UserService) AssignRole(ctx context.Context, userID, roleID, assignedBy int64) error {
_, err := s.rbacRepo.AssignRoleToUser(ctx, database.AssignRoleToUserParams{
UserID: userID,
RoleID: roleID,
AssignedBy: int64ToPgInt8(assignedBy),
})
return err
}