feat(auth): implement user registration endpoint

This commit is contained in:
2025-06-14 21:46:27 +03:00
parent 795f220e90
commit 54ce479a6e
8 changed files with 278 additions and 6 deletions

View File

@@ -0,0 +1,42 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
)
type UserHandler struct {
Service *core.UserService
}
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
var req models.RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
err := h.Service.RegisterNewUser(r.Context(), req)
if err != nil {
// Определяем, какую ошибку вернуть клиенту
switch {
case errors.Is(err, database.ErrUserExists):
http.Error(w, err.Error(), http.StatusConflict) // 409
case errors.Is(err, core.ErrInvalidUsername), errors.Is(err, core.ErrInvalidEmail), errors.Is(err, core.ErrPasswordTooShort):
http.Error(w, err.Error(), http.StatusBadRequest) // 400
default:
// Логируем внутреннюю ошибку, но не показываем ее клиенту
// log.Printf("internal server error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError) // 500
}
return
}
// Шаг 11 из ТЗ: Возвращаем 201 Created
w.WriteHeader(http.StatusCreated)
}

View File

@@ -0,0 +1,65 @@
package core
import (
"context"
"errors"
"regexp"
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidUsername = errors.New("invalid username format or length")
ErrInvalidEmail = errors.New("invalid email format")
ErrPasswordTooShort = errors.New("password is too short (minimum 8 characters)")
)
// Регулярное выражение для валидации username
var usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,16}$`)
// Регулярное выражение для валидации email (упрощенное, но эффективное)
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
type UserService struct {
Repo *database.UserRepository
}
// RegisterNewUser выполняет полный алгоритм регистрации
func (s *UserService) RegisterNewUser(ctx context.Context, req models.RegisterRequest) error {
// Шаг 2 из ТЗ: Валидация
if !usernameRegex.MatchString(req.Username) {
return ErrInvalidUsername
}
if !emailRegex.MatchString(req.Email) {
return ErrInvalidEmail
}
if len(req.Password) < 8 {
return ErrPasswordTooShort
}
// Шаг 5 из ТЗ: Генерация хеша пароля
passwordHash, err := bcrypt.GenerateFromPassword([]byte(req.Password), 12) // Стоимость 12, как в ТЗ
if err != nil {
return err
}
// Шаг 6 из ТЗ: Генерация UUID
userUUID, err := uuid.NewRandom()
if err != nil {
return err
}
user := &models.User{
UUID: userUUID,
Username: req.Username,
Email: req.Email,
PasswordHash: string(passwordHash),
Role: "user", // По умолчанию
}
// Вызываем метод репозитория для сохранения в БД
return s.Repo.CreateUserTx(ctx, user)
}

View File

@@ -0,0 +1,30 @@
package database
import (
"database/sql"
"log"
"os"
_ "github.com/jackc/pgx/v5/stdlib" // Регистрируем pgx драйвер
)
// Connect устанавливает соединение с базой данных PostgreSQL
func Connect() *sql.DB {
connStr := os.Getenv("DATABASE_URL")
if connStr == "" {
log.Fatal("DATABASE_URL environment variable is not set")
}
db, err := sql.Open("pgx", connStr)
if err != nil {
log.Fatalf("Unable to connect to database: %v\n", err)
}
// Проверяем, что соединение действительно установлено
if err = db.Ping(); err != nil {
log.Fatalf("Unable to ping database: %v\n", err)
}
log.Println("Successfully connected to PostgreSQL!")
return db
}

View File

@@ -0,0 +1,59 @@
// File: backend/internal/database/user_repository.go
package database
import (
"context"
"database/sql"
"errors"
"gitea.mrixs.me/minecraft-platform/backend/internal/models"
)
var (
ErrUserExists = errors.New("user with this username or email already exists")
)
type UserRepository struct {
DB *sql.DB
}
// CreateUserTx создает нового пользователя и его профиль в рамках одной транзакции
func (r *UserRepository) CreateUserTx(ctx context.Context, user *models.User) error {
tx, err := r.DB.BeginTx(ctx, nil)
if err != nil {
return err
}
// Гарантируем откат транзакции в случае любой ошибки
defer tx.Rollback()
// Шаг 4 из ТЗ: Проверка уникальности
var exists bool
err = tx.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM users WHERE username = $1 OR email = $2)",
user.Username, user.Email).Scan(&exists)
if err != nil {
return err
}
if exists {
return ErrUserExists
}
// Шаг 7 из ТЗ: INSERT в таблицу users. Получаем ID нового пользователя.
var newUserID int
err = tx.QueryRowContext(ctx,
"INSERT INTO users (uuid, username, email, password_hash, role) VALUES ($1, $2, $3, $4, $5) RETURNING id",
user.UUID, user.Username, user.Email, user.PasswordHash, user.Role,
).Scan(&newUserID)
if err != nil {
return err
}
// Шаг 9 из ТЗ: INSERT в таблицу profiles
_, err = tx.ExecContext(ctx, "INSERT INTO profiles (user_id) VALUES ($1)", newUserID)
if err != nil {
return err
}
// Шаг 10 из ТЗ: Коммитим транзакцию
return tx.Commit()
}

26
internal/models/user.go Normal file
View File

@@ -0,0 +1,26 @@
package models
import (
"time"
"github.com/google/uuid"
)
// User представляет структуру пользователя в таблице 'users'
type User struct {
ID int `json:"-"` // Скрываем в JSON
UUID uuid.UUID `json:"uuid"`
Username string `json:"username"`
Email string `json:"email"`
PasswordHash string `json:"-"` // Пароль никогда не отдаем
Role string `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RegisterRequest определяет структуру JSON-запроса на регистрацию
type RegisterRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
}