Sprint 1: scaffold backend (Go + Fiber + PG + Redis + MinIO)

- POST /api/v1/auth/register with mandatory consents
- POST /api/v1/auth/login
- GET  /api/v1/me (protected)
- GET  /api/v1/legal/{terms,privacy,disclaimer}
- Migrations for users, consent_log, audit_log
- bcrypt + JWT (access + refresh)
- Docker Compose stack
This commit is contained in:
ga 2026-08-20 15:41:05 +00:00
parent 72a24ab492
commit 8d754f9834
20 changed files with 1225 additions and 2 deletions

35
.env.example Normal file
View File

@ -0,0 +1,35 @@
# Server
APP_ENV=development
APP_PORT=8080
APP_URL=http://localhost:8080
# PostgreSQL
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=buhapp
POSTGRES_PASSWORD=buhapp_secret
POSTGRES_DB=buhapp
POSTGRES_SSLMODE=disable
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# MinIO
MINIO_ENDPOINT=minio:9000
MINIO_ACCESS_KEY=buhapp_minio
MINIO_SECRET_KEY=buhapp_minio_secret
MINIO_BUCKET=buhapp
MINIO_USE_SSL=false
# JWT
JWT_SECRET=change-me-in-production-please-use-long-random-string
JWT_ACCESS_TTL=3600
JWT_REFRESH_TTL=2592000
# Legal documents versions
TERMS_VERSION=1.0
PRIVACY_VERSION=1.0
DISCLAIMER_VERSION=1.0

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
.env
.env.local
*.log
.git/
*.exe
*.test
*.out
coverage/
.idea/
.vscode/

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS builder
WORKDIR /app
# dependencies
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum* ./
RUN go mod download || go mod tidy
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /buhapp-api ./cmd/server
# ---
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
addgroup -g 1000 buhapp && adduser -D -u 1000 -G buhapp buhapp
WORKDIR /app
COPY --from=builder /buhapp-api /app/buhapp-api
COPY --from=builder /app/migrations /app/migrations
USER buhapp
EXPOSE 8080
ENTRYPOINT ["/app/buhapp-api"]

View File

@ -1,3 +1,52 @@
# buhapp-backend # BuhApp Backend
Backend на Go (Fiber + PostgreSQL + Redis + MinIO) Backend на Go (Fiber) для приложения BuhApp.
## Стек
- Go 1.22+
- Fiber v2 (HTTP framework)
- PostgreSQL 16 (pgx/v5)
- Redis 7 (go-redis)
- MinIO (S3-compatible storage)
- JWT (golang-jwt)
- bcrypt (golang.org/x/crypto)
## Структура
```
buhapp-backend/
├── cmd/server/main.go — точка входа
├── internal/
│ ├── config/ — конфигурация (.env)
│ ├── db/ — PostgreSQL, миграции
│ ├── redis/ — Redis клиент
│ ├── storage/ — MinIO клиент
│ ├── auth/ — JWT, регистрация, логин
│ ├── users/ — профили
│ ├── consent/ — лог согласий (юридика)
│ ├── audit/ — audit_log
│ ├── legal/ — версии документов
│ ├── middleware/ — auth, rate limit
│ └── handlers/ — HTTP handlers
├── migrations/ — SQL миграции
├── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
```
## Запуск (dev)
```bash
docker compose up -d postgres redis minio
go run cmd/server/main.go
```
## API
См. `/buhapp-docs/TZ.md` для полного списка.
## Документация
https://git.buhapp.mygoodservice.ru/ga/buhapp-docs

137
cmd/server/main.go Normal file
View File

@ -0,0 +1,137 @@
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/buhapp/backend/internal/audit"
"github.com/buhapp/backend/internal/auth"
"github.com/buhapp/backend/internal/config"
"github.com/buhapp/backend/internal/consent"
"github.com/buhapp/backend/internal/db"
"github.com/buhapp/backend/internal/handlers"
"github.com/buhapp/backend/internal/redis"
"github.com/buhapp/backend/internal/storage"
"github.com/buhapp/backend/internal/users"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("config: %v", err)
}
ctx := context.Background()
// PostgreSQL
pg, err := db.New(ctx, cfg.PostgresDSN())
if err != nil {
log.Fatalf("postgres: %v", err)
}
defer pg.Close()
log.Println("postgres: connected")
// Run migrations
migDir := os.Getenv("MIGRATIONS_DIR")
if migDir == "" {
migDir = "./migrations"
}
if err := db.RunMigrations(ctx, pg, migDir); err != nil {
log.Fatalf("migrations: %v", err)
}
// Redis
rdb, err := redis.New(ctx, cfg.RedisAddr(), cfg.RedisPassword, cfg.RedisDB)
if err != nil {
log.Printf("redis warning: %v", err)
} else {
log.Println("redis: connected")
}
// MinIO
st, err := storage.New(ctx, cfg.MinIOEndpoint, cfg.MinIOAccessKey, cfg.MinIOSecretKey, cfg.MinIOBucket, cfg.MinIOUseSSL)
if err != nil {
log.Printf("minio warning: %v", err)
} else {
log.Println("minio: connected")
}
// Services
usersRepo := users.NewRepo(pg.Pool)
consentRepo := consent.NewRepo(pg.Pool)
auditRepo := audit.NewRepo(pg.Pool)
authSvc := auth.NewService(cfg.JWTSecret, cfg.JWTAccessTTL, cfg.JWTRefreshTTL)
authH := handlers.NewAuthHandler(cfg, usersRepo, consentRepo, auditRepo, authSvc)
// Fiber app
app := fiber.New(fiber.Config{
AppName: "buhapp-api",
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
})
app.Use(recover.New())
app.Use(logger.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "*",
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
AllowMethods: "GET, POST, PUT, DELETE, OPTIONS",
}))
// Health
app.Get("/health", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
"version": "0.1.0",
"db": pg.Pool != nil,
"redis": rdb != nil,
"storage": st != nil,
})
})
// Legal — публичные версии документов
app.Get("/api/v1/legal/terms", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"version": cfg.TermsVersion, "url": "/legal/TERMS.md"})
})
app.Get("/api/v1/legal/privacy", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"version": cfg.PrivacyVersion, "url": "/legal/PRIVACY.md"})
})
app.Get("/api/v1/legal/disclaimer", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"version": cfg.DisclaimerVersion, "url": "/legal/DISCLAIMER.md"})
})
// Auth (public)
api := app.Group("/api/v1")
api.Post("/auth/register", authH.Register)
api.Post("/auth/login", authH.Login)
// Auth (protected)
protected := api.Group("", auth.Middleware(authSvc))
protected.Get("/me", authH.Me)
// Graceful shutdown
go func() {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
log.Println("shutdown...")
_ = app.ShutdownWithTimeout(10 * time.Second)
}()
addr := fmt.Sprintf(":%d", cfg.AppPort)
log.Printf("listening on %s", addr)
if err := app.Listen(addr); err != nil {
log.Fatalf("listen: %v", err)
}
}

72
docker-compose.yml Normal file
View File

@ -0,0 +1,72 @@
services:
postgres:
image: postgres:16-alpine
container_name: buhapp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-buhapp}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-buhapp_secret}
POSTGRES_DB: ${POSTGRES_DB:-buhapp}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s
timeout: 3s
retries: 5
redis:
image: redis:7-alpine
container_name: buhapp-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
minio:
image: minio/minio:latest
container_name: buhapp-minio
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-buhapp_minio}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-buhapp_minio_secret}
command: ["server", "/data", "--console-address", ":9001"]
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 5
api:
build:
context: .
dockerfile: Dockerfile
container_name: buhapp-api
restart: unless-stopped
env_file:
- .env
environment:
POSTGRES_HOST: postgres
REDIS_HOST: redis
MINIO_ENDPOINT: minio:9000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
ports:
- "8080:8080"
volumes:
postgres_data:
redis_data:
minio_data:

15
go.mod Normal file
View File

@ -0,0 +1,15 @@
module github.com/buhapp/backend
go 1.22
require (
github.com/gofiber/fiber/v2 v2.52.5
github.com/gofiber/contrib/jwt v1.0.10
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.1
github.com/joho/godotenv v1.5.1
github.com/minio/minio-go/v7 v7.0.77
github.com/redis/go-redis/v9 v9.7.0
golang.org/x/crypto v0.27.0
)

43
internal/audit/repo.go Normal file
View File

@ -0,0 +1,43 @@
package audit
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type Event struct {
ID int64
UserID *uuid.UUID
Action string
TargetType string
TargetID string
Metadata map[string]any
IP string
UserAgent string
CreatedAt time.Time
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
func (r *Repo) Log(ctx context.Context, e *Event) error {
meta := []byte("null")
if e.Metadata != nil {
meta = []byte(`{}`) // упрощённо
// NOTE: для prod надо marshal JSON; пропускаем пока
}
return r.pool.QueryRow(ctx, `
INSERT INTO audit_log (user_id, action, target_type, target_id, metadata, ip, user_agent)
VALUES ($1, $2, NULLIF($3, ''), NULLIF($4, ''), $5::jsonb, NULLIF($6, '')::inet, $7)
RETURNING id, created_at`,
e.UserID, e.Action, e.TargetType, e.TargetID, string(meta), e.IP, e.UserAgent,
).Scan(&e.ID, &e.CreatedAt)
}

82
internal/auth/jwt.go Normal file
View File

@ -0,0 +1,82 @@
package auth
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Claims struct {
UserID uuid.UUID `json:"uid"`
jwt.RegisteredClaims
}
type Tokens struct {
Access string
Refresh string
}
type Service struct {
secret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewService(secret string, accessTTL, refreshTTL time.Duration) *Service {
return &Service{
secret: []byte(secret),
accessTTL: accessTTL,
refreshTTL: refreshTTL,
}
}
func (s *Service) Generate(userID uuid.UUID) (*Tokens, error) {
now := time.Now()
access := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)),
Subject: userID.String(),
Type: "access",
},
})
accessStr, err := access.SignedString(s.secret)
if err != nil {
return nil, err
}
refresh := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
UserID: userID,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(s.refreshTTL)),
Subject: userID.String(),
Type: "refresh",
},
})
refreshStr, err := refresh.SignedString(s.secret)
if err != nil {
return nil, err
}
return &Tokens{Access: accessStr, Refresh: refreshStr}, nil
}
func (s *Service) Parse(ctx context.Context, tokenStr string) (*Claims, error) {
claims := &Claims{}
tok, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return s.secret, nil
})
if err != nil || !tok.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}

View File

@ -0,0 +1,40 @@
package auth
import (
"errors"
"strings"
"github.com/gofiber/fiber/v2"
)
const UserIDKey = "user_id"
func Middleware(svc *Service) fiber.Handler {
return func(c *fiber.Ctx) error {
h := c.Get("Authorization")
if h == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "missing Authorization header"})
}
parts := strings.SplitN(h, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid Authorization header"})
}
claims, err := svc.Parse(c.UserContext(), parts[1])
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid token"})
}
c.Locals(UserIDKey, claims.UserID)
return c.Next()
}
}
func UserID(c *fiber.Ctx) (string, error) {
v := c.Locals(UserIDKey)
if v == nil {
return "", errors.New("no user in context")
}
if id, ok := v.(interface{ String() string }); ok {
return id.String(), nil
}
return "", errors.New("invalid user id type")
}

120
internal/config/config.go Normal file
View File

@ -0,0 +1,120 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
"github.com/joho/godotenv"
)
type Config struct {
AppEnv string
AppPort int
AppURL string
PostgresHost string
PostgresPort int
PostgresUser string
PostgresPassword string
PostgresDB string
PostgresSSLMode string
RedisHost string
RedisPort int
RedisPassword string
RedisDB int
MinIOEndpoint string
MinIOAccessKey string
MinIOSecretKey string
MinIOBucket string
MinIOUseSSL bool
JWTSecret string
JWTAccessTTL time.Duration
JWTRefreshTTL time.Duration
TermsVersion string
PrivacyVersion string
DisclaimerVersion string
}
func Load() (*Config, error) {
_ = godotenv.Load()
cfg := &Config{
AppEnv: getEnv("APP_ENV", "development"),
AppPort: getEnvInt("APP_PORT", 8080),
AppURL: getEnv("APP_URL", "http://localhost:8080"),
PostgresHost: getEnv("POSTGRES_HOST", "localhost"),
PostgresPort: getEnvInt("POSTGRES_PORT", 5432),
PostgresUser: getEnv("POSTGRES_USER", "buhapp"),
PostgresPassword: getEnv("POSTGRES_PASSWORD", "buhapp_secret"),
PostgresDB: getEnv("POSTGRES_DB", "buhapp"),
PostgresSSLMode: getEnv("POSTGRES_SSLMODE", "disable"),
RedisHost: getEnv("REDIS_HOST", "localhost"),
RedisPort: getEnvInt("REDIS_PORT", 6379),
RedisPassword: getEnv("REDIS_PASSWORD", ""),
RedisDB: getEnvInt("REDIS_DB", 0),
MinIOEndpoint: getEnv("MINIO_ENDPOINT", "localhost:9000"),
MinIOAccessKey: getEnv("MINIO_ACCESS_KEY", "buhapp_minio"),
MinIOSecretKey: getEnv("MINIO_SECRET_KEY", "buhapp_minio_secret"),
MinIOBucket: getEnv("MINIO_BUCKET", "buhapp"),
MinIOUseSSL: getEnvBool("MINIO_USE_SSL", false),
JWTSecret: getEnv("JWT_SECRET", "change-me"),
JWTAccessTTL: time.Duration(getEnvInt("JWT_ACCESS_TTL", 3600)) * time.Second,
JWTRefreshTTL: time.Duration(getEnvInt("JWT_REFRESH_TTL", 2592000)) * time.Second,
TermsVersion: getEnv("TERMS_VERSION", "1.0"),
PrivacyVersion: getEnv("PRIVACY_VERSION", "1.0"),
DisclaimerVersion: getEnv("DISCLAIMER_VERSION", "1.0"),
}
if cfg.JWTSecret == "change-me" {
return nil, fmt.Errorf("JWT_SECRET must be set")
}
return cfg, nil
}
func (c *Config) PostgresDSN() string {
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
c.PostgresUser, c.PostgresPassword,
c.PostgresHost, c.PostgresPort,
c.PostgresDB, c.PostgresSSLMode)
}
func (c *Config) RedisAddr() string {
return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort)
}
func getEnv(key, def string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return def
}
func getEnvInt(key string, def int) int {
if v, ok := os.LookupEnv(key); ok {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
func getEnvBool(key string, def bool) bool {
if v, ok := os.LookupEnv(key); ok {
if b, err := strconv.ParseBool(v); err == nil {
return b
}
}
return def
}

56
internal/consent/repo.go Normal file
View File

@ -0,0 +1,56 @@
package consent
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type DocType string
const (
DocTerms DocType = "terms"
DocPrivacy DocType = "privacy"
DocDisclaimer DocType = "disclaimer"
DocAdult DocType = "adult"
)
type Consent struct {
ID int64
UserID uuid.UUID
DocType DocType
DocVersion string
IP string
UserAgent string
AcceptedAt time.Time
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
func (r *Repo) Record(ctx context.Context, c *Consent) error {
return r.pool.QueryRow(ctx, `
INSERT INTO consent_log (user_id, doc_type, doc_version, ip, user_agent)
VALUES ($1, $2, $3, NULLIF($4, '')::inet, $5)
RETURNING id, accepted_at`,
c.UserID, c.DocType, c.DocVersion, c.IP, c.UserAgent,
).Scan(&c.ID, &c.AcceptedAt)
}
func (r *Repo) HasAccepted(ctx context.Context, userID uuid.UUID, docType DocType, version string) (bool, error) {
var exists bool
err := r.pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM consent_log
WHERE user_id=$1 AND doc_type=$2 AND doc_version=$3
)`, userID, docType, version,
).Scan(&exists)
return exists, err
}

38
internal/db/migrate.go Normal file
View File

@ -0,0 +1,38 @@
package db
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
func RunMigrations(ctx context.Context, db *DB, dir string) error {
files, err := os.ReadDir(dir)
if err != nil {
return fmt.Errorf("read migrations dir: %w", err)
}
var ups []string
for _, f := range files {
if !f.IsDir() && strings.HasSuffix(f.Name(), ".up.sql") {
ups = append(ups, f.Name())
}
}
sort.Strings(ups)
for _, name := range ups {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %s: %w", name, err)
}
if _, err := db.Pool.Exec(ctx, string(data)); err != nil {
return fmt.Errorf("exec %s: %w", name, err)
}
fmt.Printf("[migrate] applied %s\n", name)
}
return nil
}

37
internal/db/postgres.go Normal file
View File

@ -0,0 +1,37 @@
package db
import (
"context"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type DB struct {
Pool *pgxpool.Pool
}
func New(ctx context.Context, dsn string) (*DB, error) {
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil {
return nil, err
}
cfg.MaxConns = 25
cfg.MinConns = 2
cfg.MaxConnLifetime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, err
}
if err := pool.Ping(ctx); err != nil {
return nil, err
}
return &DB{Pool: pool}, nil
}
func (d *DB) Close() {
d.Pool.Close()
}

240
internal/handlers/auth.go Normal file
View File

@ -0,0 +1,240 @@
package handlers
import (
"regexp"
"strings"
"time"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"github.com/buhapp/backend/internal/audit"
"github.com/buhapp/backend/internal/auth"
"github.com/buhapp/backend/internal/config"
"github.com/buhapp/backend/internal/consent"
"github.com/buhapp/backend/internal/users"
)
type AuthHandler struct {
cfg *config.Config
users *users.Repo
consent *consent.Repo
audit *audit.Repo
auth *auth.Service
}
func NewAuthHandler(cfg *config.Config, u *users.Repo, c *consent.Repo, a *audit.Repo, au *auth.Service) *AuthHandler {
return &AuthHandler{cfg: cfg, users: u, consent: c, audit: a, auth: au}
}
type registerRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
Name string `json:"name"`
Birthdate string `json:"birthdate"`
Gender string `json:"gender"`
City string `json:"city"`
Consents consentsBlock `json:"consents"`
}
type consentsBlock struct {
Adult bool `json:"adult"`
Terms bool `json:"terms"`
Privacy bool `json:"privacy"`
Disclaimer bool `json:"disclaimer"`
}
var emailRe = regexp.MustCompile(`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`)
var phoneRe = regexp.MustCompile(`^\+?[0-9]{10,15}$`)
func (h *AuthHandler) Register(c *fiber.Ctx) error {
var req registerRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
// validate required consents
if !req.Consents.Adult || !req.Consents.Terms || !req.Consents.Privacy || !req.Consents.Disclaimer {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "all consents required: adult, terms, privacy, disclaimer",
})
}
// normalize
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Phone = strings.TrimSpace(req.Phone)
req.Name = strings.TrimSpace(req.Name)
if req.Email == "" && req.Phone == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email or phone required"})
}
if req.Email != "" && !emailRe.MatchString(req.Email) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid email"})
}
if req.Phone != "" && !phoneRe.MatchString(req.Phone) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid phone"})
}
if len(req.Password) < 8 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password must be at least 8 chars"})
}
if req.Name == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name required"})
}
var birth *time.Time
if req.Birthdate != "" {
t, err := time.Parse("2006-01-02", req.Birthdate)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "birthdate must be YYYY-MM-DD"})
}
if t.After(time.Now().AddDate(-18, 0, 0)) {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "must be 18+"})
}
birth = &t
}
if req.Gender != "" && req.Gender != "m" && req.Gender != "f" && req.Gender != "o" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "gender must be m, f or o"})
}
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "hash failed"})
}
u := &users.User{
Email: req.Email,
Phone: req.Phone,
Name: req.Name,
Birthdate: birth,
Gender: req.Gender,
City: req.City,
}
if err := h.users.CreateWithPassword(c.UserContext(), u, string(hash)); err != nil {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": "user already exists or db error"})
}
ip := c.IP()
ua := c.Get("User-Agent")
// log consents
for _, item := range []struct {
Type consent.DocType
Ver string
}{
{consent.DocAdult, "1.0"},
{consent.DocTerms, h.cfg.TermsVersion},
{consent.DocPrivacy, h.cfg.PrivacyVersion},
{consent.DocDisclaimer, h.cfg.DisclaimerVersion},
} {
_ = h.consent.Record(c.UserContext(), &consent.Consent{
UserID: u.ID, DocType: item.Type, DocVersion: item.Ver,
IP: ip, UserAgent: ua,
})
}
// audit
uid := u.ID
_ = h.audit.Log(c.UserContext(), &audit.Event{
UserID: &uid, Action: "user.register",
IP: ip, UserAgent: ua,
})
tokens, err := h.auth.Generate(u.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token failed"})
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"user": fiber.Map{
"id": u.ID,
"email": u.Email,
"phone": u.Phone,
"name": u.Name,
"city": u.City,
"gender": u.Gender,
"birthdate": u.Birthdate,
},
"tokens": tokens,
})
}
type loginRequest struct {
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
}
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req loginRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid JSON"})
}
req.Email = strings.TrimSpace(strings.ToLower(req.Email))
req.Phone = strings.TrimSpace(req.Phone)
if req.Email == "" && req.Phone == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "email or phone required"})
}
if len(req.Password) < 1 {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "password required"})
}
var u *users.User
var hash string
var err error
if req.Email != "" {
u, hash, err = h.users.GetByEmail(c.UserContext(), req.Email)
} else {
// MVP: поиск по телефону пока не реализован — добавим позже
return c.Status(fiber.StatusNotImplemented).JSON(fiber.Map{"error": "login by phone not implemented yet"})
}
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if u == nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
if u.IsBlocked {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "account blocked"})
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "invalid credentials"})
}
tokens, err := h.auth.Generate(u.ID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "token failed"})
}
uid := u.ID
_ = h.audit.Log(c.UserContext(), &audit.Event{
UserID: &uid, Action: "user.login",
IP: c.IP(), UserAgent: c.Get("User-Agent"),
})
return c.JSON(fiber.Map{
"user": u,
"tokens": tokens,
})
}
func (h *AuthHandler) Me(c *fiber.Ctx) error {
idStr, err := auth.UserID(c)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "no user"})
}
id, err := uuid.Parse(idStr)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "bad user id"})
}
u, err := h.users.GetByID(c.UserContext(), id)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "db error"})
}
if u == nil {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
}
return c.JSON(u)
}

23
internal/redis/redis.go Normal file
View File

@ -0,0 +1,23 @@
package redis
import (
"context"
"time"
"github.com/redis/go-redis/v9"
)
func New(ctx context.Context, addr, password string, db int) (*redis.Client, error) {
c := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
DB: db,
DialTimeout: 3 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
})
if err := c.Ping(ctx).Err(); err != nil {
return nil, err
}
return c, nil
}

36
internal/storage/minio.go Normal file
View File

@ -0,0 +1,36 @@
package storage
import (
"context"
"fmt"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Storage struct {
Client *minio.Client
Bucket string
}
func New(ctx context.Context, endpoint, accessKey, secretKey, bucket string, useSSL bool) (*Storage, error) {
client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: useSSL,
})
if err != nil {
return nil, fmt.Errorf("minio: %w", err)
}
exists, err := client.BucketExists(ctx, bucket)
if err != nil {
return nil, fmt.Errorf("bucket exists check: %w", err)
}
if !exists {
if err := client.MakeBucket(ctx, bucket, minio.MakeBucketOptions{}); err != nil {
return nil, fmt.Errorf("make bucket: %w", err)
}
}
return &Storage{Client: client, Bucket: bucket}, nil
}

103
internal/users/repo.go Normal file
View File

@ -0,0 +1,103 @@
package users
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type User struct {
ID uuid.UUID
Email string
Phone string
Name string
Birthdate *time.Time
Gender string
City string
Bio string
PhotoURL string
IsVerified bool
IsBlocked bool
CreatedAt time.Time
UpdatedAt time.Time
LastSeenAt *time.Time
}
type Repo struct {
pool *pgxpool.Pool
}
func NewRepo(pool *pgxpool.Pool) *Repo {
return &Repo{pool: pool}
}
func (r *Repo) CreateWithPassword(ctx context.Context, u *User, passwordHash string) error {
return r.pool.QueryRow(ctx, `
INSERT INTO users (email, phone, password_hash, name, birthdate, gender, city)
VALUES (NULLIF($1, ''), NULLIF($2, ''), $3, $4, $5, NULLIF($6, ''), NULLIF($7, ''))
RETURNING id, created_at, updated_at`,
u.Email, u.Phone, passwordHash, u.Name, u.Birthdate, u.Gender, u.City,
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}
func (r *Repo) GetByID(ctx context.Context, id uuid.UUID) (*User, error) {
u := &User{}
var email, phone, gender, city, bio, photo *string
err := r.pool.QueryRow(ctx, `
SELECT id, email, phone, name, birthdate, gender, city, bio, photo_url,
is_verified, is_blocked, created_at, updated_at, last_seen_at
FROM users WHERE id=$1`, id,
).Scan(&u.ID, &email, &phone, &u.Name, &u.Birthdate, &gender, &city, &bio, &photo,
&u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
if email != nil {
u.Email = *email
}
if phone != nil {
u.Phone = *phone
}
if gender != nil {
u.Gender = *gender
}
if city != nil {
u.City = *city
}
if bio != nil {
u.Bio = *bio
}
if photo != nil {
u.PhotoURL = *photo
}
return u, nil
}
func (r *Repo) GetByEmail(ctx context.Context, email string) (*User, string, error) {
u := &User{}
var hash string
var em *string
err := r.pool.QueryRow(ctx, `
SELECT id, email, password_hash, name, birthdate, gender, city, bio, photo_url,
is_verified, is_blocked, created_at, updated_at, last_seen_at
FROM users WHERE email=$1`, email,
).Scan(&u.ID, &em, &hash, &u.Name, &u.Birthdate, &u.Gender, &u.City, &u.Bio, &u.PhotoURL,
&u.IsVerified, &u.IsBlocked, &u.CreatedAt, &u.UpdatedAt, &u.LastSeenAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
if em != nil {
u.Email = *em
}
return u, hash, nil
}

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS consent_log;
DROP TABLE IF EXISTS users;

View File

@ -0,0 +1,56 @@
-- BuhApp database schema
-- Sprint 1: users, consents, audit
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- USERS
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
email TEXT UNIQUE,
phone TEXT UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
birthdate DATE,
gender TEXT CHECK (gender IN ('m','f','o')),
city TEXT,
bio TEXT,
photo_url TEXT,
is_verified BOOLEAN DEFAULT FALSE,
is_blocked BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_users_last_seen ON users(last_seen_at);
-- CONSENT LOG (юр. документы, согласия при регистрации)
CREATE TABLE IF NOT EXISTS consent_log (
id BIGSERIAL PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
doc_type TEXT NOT NULL CHECK (doc_type IN ('terms','privacy','disclaimer','adult')),
doc_version TEXT NOT NULL,
ip INET,
user_agent TEXT,
accepted_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_consent_user ON consent_log(user_id);
-- AUDIT LOG (журнал действий для разборов)
CREATE TABLE IF NOT EXISTS audit_log (
id BIGSERIAL PRIMARY KEY,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
metadata JSONB,
ip INET,
user_agent TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_user ON audit_log(user_id);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at);