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

@@ -1,27 +1,44 @@
// File: backend/cmd/server/main.go
package main
import (
"log"
"net/http"
"gitea.mrixs.me/minecraft-platform/backend/internal/api"
"gitea.mrixs.me/minecraft-platform/backend/internal/core"
"gitea.mrixs.me/minecraft-platform/backend/internal/database"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
// Создаем новый роутер
r := chi.NewRouter()
// Инициализируем соединение с БД
db := database.Connect()
defer db.Close()
// Используем стандартные middleware для логирования и восстановления после паник
// Собираем наши зависимости (Dependency Injection)
userRepo := &database.UserRepository{DB: db}
userService := &core.UserService{Repo: userRepo}
userHandler := &api.UserHandler{Service: userService}
// Создаем роутер
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Определяем простой маршрут
// Группа маршрутов для API
r.Route("/api", func(r chi.Router) {
r.Post("/register", userHandler.Register)
// Здесь будут другие маршруты API
})
// Маршрут для проверки, что сервер жив
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Backend server is running!"))
})
// Запускаем сервер на порту 8080, как мы указали в Caddyfile
log.Println("Starting backend server on :8080")
if err := http.ListenAndServe(":8080", r); err != nil {
log.Fatalf("Failed to start server: %v", err)

12
go.mod
View File

@@ -2,4 +2,14 @@ module gitea.mrixs.me/minecraft-platform/backend
go 1.24.1
require github.com/go-chi/chi/v5 v5.2.1 // indirect
require (
github.com/go-chi/chi/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/text v0.26.0 // indirect
)

23
go.sum
View File

@@ -1,2 +1,25 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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"`
}