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:
parent
72a24ab492
commit
8d754f9834
35
.env.example
Normal file
35
.env.example
Normal 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
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.git/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
coverage/
|
||||
.idea/
|
||||
.vscode/
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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"]
|
||||
53
README.md
53
README.md
@ -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
137
cmd/server/main.go
Normal 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
72
docker-compose.yml
Normal 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
15
go.mod
Normal 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
43
internal/audit/repo.go
Normal 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
82
internal/auth/jwt.go
Normal 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
|
||||
}
|
||||
40
internal/auth/middleware.go
Normal file
40
internal/auth/middleware.go
Normal 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
120
internal/config/config.go
Normal 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
56
internal/consent/repo.go
Normal 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
38
internal/db/migrate.go
Normal 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
37
internal/db/postgres.go
Normal 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
240
internal/handlers/auth.go
Normal 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
23
internal/redis/redis.go
Normal 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
36
internal/storage/minio.go
Normal 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
103
internal/users/repo.go
Normal 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
|
||||
}
|
||||
3
migrations/0001_init.down.sql
Normal file
3
migrations/0001_init.down.sql
Normal file
@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS audit_log;
|
||||
DROP TABLE IF EXISTS consent_log;
|
||||
DROP TABLE IF EXISTS users;
|
||||
56
migrations/0001_init.up.sql
Normal file
56
migrations/0001_init.up.sql
Normal 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);
|
||||
Loading…
Reference in New Issue
Block a user