init: learning-service skeleton
Микросервис обучения портала: тесты, курсы, видео-уроки, доступы, public-ссылки для кандидатов с email-валидацией. В этой итерации: - Skeleton (config, migrate, main, health) по паттерну tasks/candidates - Migration 001_init: 10 таблиц (tests/questions/answers/attempts/ attempt_answers + courses/lessons/lesson_progress + access_grants + public_tokens) с подробными комментариями why - Tests: полный CRUD + вопросы/ответы; non-owner'у is_correct и explanation скрываются в выдаче - Заглушки 501 для attempts / courses / lessons / video-stream / access / public-tokens — следующие итерации - k8s: namespace, configmap, secrets, postgres, deployment с HPA, service с portal-discovery annotations - Dockerfile, Makefile, .gitignore См. README.md для полного списка отложенного и инструкций запуска. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
bin/
|
||||||
|
/server
|
||||||
|
*.exe
|
||||||
|
tmp/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# macOS Finder metadata
|
||||||
|
.DS_Store
|
||||||
15
Dockerfile.server
Normal file
15
Dockerfile.server
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
FROM golang:1.25-alpine AS build
|
||||||
|
WORKDIR /src
|
||||||
|
# GOPRIVATE — Go должен фетчить portal-common напрямую, минуя proxy.golang.org.
|
||||||
|
ENV GOPRIVATE=gitea.estateliga.work
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /learning-server ./cmd/server
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /learning-server /learning-server
|
||||||
|
COPY migrations /migrations
|
||||||
|
EXPOSE 3001
|
||||||
|
ENTRYPOINT ["/learning-server"]
|
||||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
.PHONY: build run test docker tidy
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/learning-server ./cmd/server
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run ./cmd/server
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
tidy:
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
docker:
|
||||||
|
docker build -f Dockerfile.server -t learning-server:dev .
|
||||||
100
README.md
Normal file
100
README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# learning-service
|
||||||
|
|
||||||
|
Микросервис обучения портала: тесты, курсы с видео-уроками,
|
||||||
|
гранулярные доступы, public-ссылки для кандидатов с email-валидацией.
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- Go 1.25, chi/v5, pgx/v5, portal-common
|
||||||
|
- PostgreSQL (отдельная БД `learning`)
|
||||||
|
- MinIO (bucket `learning-videos`) — стрим-прокси, MinIO URL не светится
|
||||||
|
клиенту (по аналогии с telephony record stream)
|
||||||
|
- Redis (eventbus, опционально) — публикация событий
|
||||||
|
`test.submitted` / `course.enrolled` для portal-real-time
|
||||||
|
- k8s (namespace `learning`)
|
||||||
|
|
||||||
|
## Сущности (см. `migrations/001_init.up.sql`)
|
||||||
|
|
||||||
|
| Сущность | Назначение |
|
||||||
|
|---|---|
|
||||||
|
| `tests` | Тесты: title, passing_score, max_attempts, time_limit_sec, is_published, owner |
|
||||||
|
| `test_questions` | Вопросы (single / multi / text), points, explanation |
|
||||||
|
| `test_answers` | Варианты ответа с is_correct |
|
||||||
|
| `test_attempts` | Попытки прохождения: user_id ИЛИ public_token_id; score/passed |
|
||||||
|
| `test_attempt_answers` | Ответы пользователя в попытке (JSONB payload) |
|
||||||
|
| `courses` | Курсы: title, slug, cover, is_published |
|
||||||
|
| `lessons` | Уроки курса: markdown + video_key + опциональный test_id |
|
||||||
|
| `lesson_progress` | Прогресс «просмотрено / завершено» по урокам |
|
||||||
|
| `access_grants` | Гранулярный ACL: ресурс × subject (user/role/department/position/public) |
|
||||||
|
| `public_tokens` | Одноразовые ссылки кандидатам с intended_email + max_attempts |
|
||||||
|
|
||||||
|
## Permission-модель (на стороне portal-backend)
|
||||||
|
|
||||||
|
| Permission | Кому | Что даёт |
|
||||||
|
|---|---|---|
|
||||||
|
| `service.learning.access` | Все активные сотрудники | Видимость раздела, прохождение назначенного |
|
||||||
|
| `service.learning.author` | HR, тренеры, лиды | Создание/редактирование своих материалов, public-ссылки |
|
||||||
|
| `service.learning.admin` | Admin | Глобальный доступ к чужим материалам, аналитика |
|
||||||
|
|
||||||
|
См. portal-backend `migrations/049_learning_permissions.up.sql`.
|
||||||
|
|
||||||
|
## Что реализовано в этой итерации
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Skeleton сервиса (config, migrate, main, health)
|
||||||
|
- Migration `001_init.up.sql` — полная схема всех 10 таблиц
|
||||||
|
- **Tests:** полный CRUD + вопросы/ответы + scoped-выдача (не-владельцу
|
||||||
|
is_correct скрывается, explanation тоже)
|
||||||
|
- Заглушки `notImplemented` (501) для остальных эндпоинтов
|
||||||
|
|
||||||
|
**Portal-backend:**
|
||||||
|
- Migration `049_learning_permissions.up.sql` — 3 пермы + грантование admin'у
|
||||||
|
- `/api/learning/*` — прокси под `service.learning.access`
|
||||||
|
- `/public/learning/*` — прокси без auth для кандидатов
|
||||||
|
- `Services.LearningURL` + env-переменная
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Route `/learning/*` под guard'ом `service.learning.access`
|
||||||
|
- Landing-страница с тремя картчоками
|
||||||
|
- `/learning/tests` — список + создание (полностью рабочий)
|
||||||
|
- `/learning/courses`, `/learning/admin` — заглушки `LearningStubComponent`
|
||||||
|
- Sidebar-пункт «Обучение» + sub-навигация
|
||||||
|
|
||||||
|
**k8s:** полный набор манифестов (namespace / configmap / secrets /
|
||||||
|
postgres / deployment с HPA / service с portal-discovery annotations).
|
||||||
|
|
||||||
|
## Что отложено в следующие итерации
|
||||||
|
|
||||||
|
| Фича | План |
|
||||||
|
|---|---|
|
||||||
|
| **Attempts (прохождение)** | Repository + handler: start attempt → fetch questions → submit → auto-grade (single/multi) + manual review для text |
|
||||||
|
| **Courses + Lessons CRUD** | Repository + handler по тому же паттерну что Tests |
|
||||||
|
| **Видео-стрим** | MinIO wrapper в `internal/storage/`, handler `/lessons/{id}/video/stream` с Range-поддержкой; копировать stream-pattern из telephony |
|
||||||
|
| **Access grants** | Repository + handler + helper «может ли user X пройти ресурс Y» (учитывает user/role/department/position иерархию через portal-internal API) |
|
||||||
|
| **Public tokens** | Repository + handler: создание (HR из portal'а), resolve (email-match) — на портале и стрим в proxy без auth |
|
||||||
|
| **Email-отправка** | Через portal Kerio integration — handler выдаёт сформированный URL + текст письма HR'у, реальная отправка через portal |
|
||||||
|
| **Frontend: конструктор теста** | Drag-and-drop редактор вопросов/ответов (паттерн как kanban-column в tasks) |
|
||||||
|
| **Frontend: плеер видео-урока** | Кастомный плеер по аналогии с call-audio-player (blob URL, controlsList=nodownload) |
|
||||||
|
| **Frontend: public-страница кандидата** | Отдельный route `/public/learning/test/:token` без layout'а портала |
|
||||||
|
|
||||||
|
## Локальный запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d postgres minio # или используй существующие
|
||||||
|
make tidy
|
||||||
|
DATABASE_URL=postgres://learning:learning@localhost:5432/learning?sslmode=disable \
|
||||||
|
INTERNAL_API_KEY=devkey \
|
||||||
|
MINIO_ENDPOINT=localhost:9000 \
|
||||||
|
MINIO_ACCESS_KEY=minioadmin \
|
||||||
|
MINIO_SECRET_KEY=minioadmin \
|
||||||
|
make run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl apply -k k8s/
|
||||||
|
# Образ собирается в CI или вручную:
|
||||||
|
docker build -f Dockerfile.server -t localhost:30300/admin/learning-server:latest .
|
||||||
|
docker push localhost:30300/admin/learning-server:latest
|
||||||
|
```
|
||||||
133
cmd/server/main.go
Normal file
133
cmd/server/main.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// learning-server — микросервис обучения: тесты, курсы (с видео),
|
||||||
|
// гранулярные доступы и public-ссылки для кандидатов с email-валидацией.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
commondb "gitea.estateliga.work/admin/portal-common/db"
|
||||||
|
commonmw "gitea.estateliga.work/admin/portal-common/middleware"
|
||||||
|
|
||||||
|
"learning-service/internal/config"
|
||||||
|
"learning-service/internal/handler"
|
||||||
|
"learning-service/internal/migrate"
|
||||||
|
"learning-service/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||||
|
|
||||||
|
cfg := config.Load()
|
||||||
|
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
pool, err := commondb.ConnectURL(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("connect database", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
if err := migrate.Run(context.Background(), pool, cfg.MigrationsDir); err != nil {
|
||||||
|
slog.Error("run migrations", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
testRepo := repository.NewTestRepository(pool)
|
||||||
|
|
||||||
|
healthH := handler.NewHealthHandler(pool)
|
||||||
|
testH := handler.NewTestHandler(testRepo)
|
||||||
|
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Use(chimw.RequestID)
|
||||||
|
r.Use(chimw.RealIP)
|
||||||
|
r.Use(chimw.Recoverer)
|
||||||
|
|
||||||
|
r.Get("/healthz", healthH.Healthz)
|
||||||
|
r.Get("/readyz", healthH.Readyz)
|
||||||
|
|
||||||
|
r.Route("/api", func(r chi.Router) {
|
||||||
|
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
|
||||||
|
|
||||||
|
// Tests CRUD
|
||||||
|
r.Get("/tests", testH.List)
|
||||||
|
r.Post("/tests", testH.Create)
|
||||||
|
r.Get("/tests/{id}", testH.Get)
|
||||||
|
r.Patch("/tests/{id}", testH.Update)
|
||||||
|
r.Delete("/tests/{id}", testH.Delete)
|
||||||
|
|
||||||
|
// Questions внутри теста
|
||||||
|
r.Get("/tests/{id}/questions", testH.ListQuestions)
|
||||||
|
r.Post("/tests/{id}/questions", testH.CreateQuestion)
|
||||||
|
r.Delete("/tests/{id}/questions/{questionId}", testH.DeleteQuestion)
|
||||||
|
|
||||||
|
// Заглушки на следующие итерации. Возвращают 501 — фронт может
|
||||||
|
// рендерить «функция в разработке», если случайно дёрнет.
|
||||||
|
r.HandleFunc("/tests/{id}/attempts", notImplemented)
|
||||||
|
r.HandleFunc("/attempts/{id}", notImplemented)
|
||||||
|
r.HandleFunc("/attempts/{id}/submit", notImplemented)
|
||||||
|
r.HandleFunc("/courses", notImplemented)
|
||||||
|
r.HandleFunc("/courses/{id}", notImplemented)
|
||||||
|
r.HandleFunc("/courses/{id}/lessons", notImplemented)
|
||||||
|
r.HandleFunc("/lessons/{id}", notImplemented)
|
||||||
|
r.HandleFunc("/lessons/{id}/video", notImplemented)
|
||||||
|
r.HandleFunc("/lessons/{id}/video/stream", notImplemented)
|
||||||
|
r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented)
|
||||||
|
r.HandleFunc("/public-tokens", notImplemented)
|
||||||
|
r.HandleFunc("/public-tokens/{id}", notImplemented)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Public endpoints — без InternalAuth (кандидаты ходят анонимно
|
||||||
|
// по token'у). Открываем минимум: проверка токена + старт attempt +
|
||||||
|
// сабмит. Содержательно гейтим через token-валидацию в самом handler'е.
|
||||||
|
r.Route("/public", func(r chi.Router) {
|
||||||
|
r.HandleFunc("/learning/resolve/{token}", notImplemented)
|
||||||
|
r.HandleFunc("/learning/attempts/{id}", notImplemented)
|
||||||
|
r.HandleFunc("/learning/attempts/{id}/submit", notImplemented)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: ":" + cfg.ServerPort,
|
||||||
|
Handler: r,
|
||||||
|
ReadTimeout: 15 * time.Second,
|
||||||
|
WriteTimeout: 30 * time.Second,
|
||||||
|
IdleTimeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
slog.Info("learning server starting", "port", cfg.ServerPort, "pod", cfg.PodName)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
slog.Error("server error", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-ctx.Done()
|
||||||
|
slog.Info("shutting down server...")
|
||||||
|
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
slog.Error("server shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
slog.Info("server stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// notImplemented — заглушка для эндпоинтов, которые есть в схеме, но
|
||||||
|
// ещё не имплементированы. Возвращает 501 + понятное сообщение, чтобы
|
||||||
|
// фронт мог отрендерить «в разработке» вместо generic 500.
|
||||||
|
func notImplemented(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"not implemented yet — следующая итерация"}`))
|
||||||
|
}
|
||||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
module learning-service
|
||||||
|
|
||||||
|
go 1.25.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.estateliga.work/admin/portal-common v0.2.0
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
// Если ведёшь параллельно правки в portal-common — раскомментируй replace.
|
||||||
|
// replace gitea.estateliga.work/admin/portal-common => ../portal-common
|
||||||
32
go.sum
Normal file
32
go.sum
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
gitea.estateliga.work/admin/portal-common v0.2.0 h1:TwSxTDwSWnPJUGuCfjSy1f++MxvDIZ+HCUNMC3EFNcE=
|
||||||
|
gitea.estateliga.work/admin/portal-common v0.2.0/go.mod h1:C860q6g38KVMsv+mKv6k1Vm7smVRCycl+N6r63TElnk=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||||
|
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||||
|
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.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||||
|
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
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=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
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=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
97
internal/config/config.go
Normal file
97
internal/config/config.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
// Package config — env-based конфиг learning-сервиса. Стиль envStr идентичен
|
||||||
|
// candidates/tasks/booking — чтобы k8s-манифесты выглядели единообразно.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
ServerPort string
|
||||||
|
DatabaseURL string
|
||||||
|
MigrationsDir string
|
||||||
|
|
||||||
|
// InternalAPIKey — общий ключ с порталом для server-to-server auth.
|
||||||
|
// Portal-proxy ставит X-Internal-Key + X-User-Id; сервис верифицирует
|
||||||
|
// ключ и доверяет X-User-Id (JWT уже провалидирован на портале).
|
||||||
|
InternalAPIKey string
|
||||||
|
|
||||||
|
// PortalURL — для in-app уведомлений (назначен курс/тест/пройден тест).
|
||||||
|
// Если пусто — notify-вызовы тихо no-op'ятся, основной flow работает.
|
||||||
|
PortalURL string
|
||||||
|
|
||||||
|
// PublicBaseURL — внешний origin, под которым отдаются public-ссылки
|
||||||
|
// для кандидатов (например, https://portal.estateliga.work). Используется
|
||||||
|
// в формате URL'ей: <PUBLIC_BASE>/public/learning/test/<token>.
|
||||||
|
PublicBaseURL string
|
||||||
|
|
||||||
|
// MinIO для видео-уроков. Структура та же что в telephony (records bucket),
|
||||||
|
// но bucket отдельный — public-link'и читают видео через стрим-прокси.
|
||||||
|
MinIOEndpoint string
|
||||||
|
MinIOAccessKey string
|
||||||
|
MinIOSecretKey string
|
||||||
|
MinIOBucket string
|
||||||
|
MinIOUseSSL bool
|
||||||
|
|
||||||
|
// RedisAddr — пустой = eventbus отключён, сервис работает без публикации
|
||||||
|
// событий (попадание кандидата на тест не уведомит HR в realtime).
|
||||||
|
RedisAddr string
|
||||||
|
RedisPassword string
|
||||||
|
RedisDB int
|
||||||
|
|
||||||
|
PodName string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load() *Config {
|
||||||
|
return &Config{
|
||||||
|
ServerPort: envStr("SERVER_PORT", "3001"),
|
||||||
|
DatabaseURL: envStr("DATABASE_URL", "postgres://learning:learning@localhost:5432/learning?sslmode=disable"),
|
||||||
|
MigrationsDir: envStr("MIGRATIONS_DIR", "/migrations"),
|
||||||
|
InternalAPIKey: envStr("INTERNAL_API_KEY", envStr("PORTAL_INTERNAL_API_KEY", "")),
|
||||||
|
PortalURL: envStr("PORTAL_URL", ""),
|
||||||
|
PublicBaseURL: envStr("PUBLIC_BASE_URL", ""),
|
||||||
|
MinIOEndpoint: envStr("MINIO_ENDPOINT", ""),
|
||||||
|
MinIOAccessKey: envStr("MINIO_ACCESS_KEY", ""),
|
||||||
|
MinIOSecretKey: envStr("MINIO_SECRET_KEY", ""),
|
||||||
|
MinIOBucket: envStr("MINIO_BUCKET", "learning-videos"),
|
||||||
|
MinIOUseSSL: envBool("MINIO_USE_SSL", false),
|
||||||
|
RedisAddr: envStr("REDIS_ADDR", ""),
|
||||||
|
RedisPassword: envStr("REDIS_PASSWORD", ""),
|
||||||
|
RedisDB: envInt("REDIS_DB", 0),
|
||||||
|
PodName: envStr("POD_NAME", hostname()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, def int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envBool(key string, def bool) bool {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if b, err := strconv.ParseBool(v); err == nil {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func envStr(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostname() string {
|
||||||
|
h, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
34
internal/handler/health.go
Normal file
34
internal/handler/health.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type HealthHandler struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
|
||||||
|
return &HealthHandler{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthz — liveness. Не дёргает БД; жив если процесс отвечает.
|
||||||
|
func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readyz — readiness. Один Ping к БД с таймаутом — если не отвечает,
|
||||||
|
// k8s выкидывает pod из service-балансира.
|
||||||
|
func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := h.pool.Ping(ctx); err != nil {
|
||||||
|
writeError(w, http.StatusServiceUnavailable, "db not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||||
|
}
|
||||||
63
internal/handler/helpers.go
Normal file
63
internal/handler/helpers.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"learning-service/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]any{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRepoError — маппит repository.Err* на HTTP-коды. Для остальных
|
||||||
|
// логируем и отдаём 500 без деталей (наружу не светим внутренние ошибки).
|
||||||
|
func writeRepoError(w http.ResponseWriter, r *http.Request, err error, action string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, repository.ErrNotFound):
|
||||||
|
writeError(w, http.StatusNotFound, "not found")
|
||||||
|
case errors.Is(err, repository.ErrForbidden):
|
||||||
|
writeError(w, http.StatusForbidden, "forbidden")
|
||||||
|
default:
|
||||||
|
slog.Error("repo error", "action", action, "error", err, "path", r.URL.Path)
|
||||||
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(r *http.Request, v any) error {
|
||||||
|
dec := json.NewDecoder(r.Body)
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
return dec.Decode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUUID(s string) (uuid.UUID, error) {
|
||||||
|
if s == "" {
|
||||||
|
return uuid.Nil, errors.New("empty uuid")
|
||||||
|
}
|
||||||
|
return uuid.Parse(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// userIDFromHeader — portal-gateway проставляет X-User-Id после валидации JWT.
|
||||||
|
// Все НЕ-internal handler'ы читают отсюда; пустой = unauthorized.
|
||||||
|
func userIDFromHeader(r *http.Request) (uuid.UUID, bool) {
|
||||||
|
v := r.Header.Get("X-User-Id")
|
||||||
|
if v == "" {
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
id, err := uuid.Parse(v)
|
||||||
|
if err != nil {
|
||||||
|
return uuid.Nil, false
|
||||||
|
}
|
||||||
|
return id, true
|
||||||
|
}
|
||||||
260
internal/handler/test.go
Normal file
260
internal/handler/test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"learning-service/internal/model"
|
||||||
|
"learning-service/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestHandler struct {
|
||||||
|
repo *repository.TestRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestHandler(repo *repository.TestRepository) *TestHandler {
|
||||||
|
return &TestHandler{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List — GET /tests. Параметры:
|
||||||
|
// ?mine=true — только мои (owner_user_id = X-User-Id);
|
||||||
|
// без mine — published тесты (для прохождения).
|
||||||
|
//
|
||||||
|
// MVP: ещё не подключён access_grants-фильтр; до этого момента «published»
|
||||||
|
// = «всем видно». Следующая итерация: handler через AccessRepository
|
||||||
|
// получит visibleIDs и передаст в repo.List.
|
||||||
|
func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mine := r.URL.Query().Get("mine") == "true"
|
||||||
|
var ownerFilter *uuid.UUID
|
||||||
|
if mine {
|
||||||
|
ownerFilter = &uid
|
||||||
|
}
|
||||||
|
tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "list tests")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": tests})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TestHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TestHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.CreateTestRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Title) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "title is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Create(r.Context(), uid, req)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "create test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update / Delete: пока без access-проверки (только owner). Когда подключим
|
||||||
|
// access_grants с can_manage, разрешим co-owner'ам тоже редактировать.
|
||||||
|
func (h *TestHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := h.repo.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test for update")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing.OwnerUserID != uid {
|
||||||
|
writeError(w, http.StatusForbidden, "only owner can edit")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.UpdateTestRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Update(r.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "update test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TestHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
existing, err := h.repo.Get(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test for delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing.OwnerUserID != uid {
|
||||||
|
writeError(w, http.StatusForbidden, "only owner can delete")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.Delete(r.Context(), id); err != nil {
|
||||||
|
writeRepoError(w, r, err, "delete test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListQuestions — вопросы теста с ответами. Если запросивший не владелец —
|
||||||
|
// is_correct поле обнуляется в ответе (показывать правильные ответы до
|
||||||
|
// сабмита нельзя — иначе тест теряет смысл).
|
||||||
|
func (h *TestHandler) ListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testID, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Get(r.Context(), testID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qs, err := h.repo.ListQuestions(r.Context(), testID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "list questions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Не-владельцу скрываем is_correct, чтобы он не подсмотрел через
|
||||||
|
// DevTools правильные ответы до сабмита.
|
||||||
|
if t.OwnerUserID != uid {
|
||||||
|
for i := range qs {
|
||||||
|
for j := range qs[i].Answers {
|
||||||
|
qs[i].Answers[j].IsCorrect = false
|
||||||
|
}
|
||||||
|
// Объяснение тоже не светим — оно потенциально содержит
|
||||||
|
// разбор правильного ответа.
|
||||||
|
qs[i].Explanation = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"items": qs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testID, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Get(r.Context(), testID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.OwnerUserID != uid {
|
||||||
|
writeError(w, http.StatusForbidden, "only owner can add questions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req model.CreateQuestionRequest
|
||||||
|
if err := decodeJSON(r, &req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Text) == "" {
|
||||||
|
writeError(w, http.StatusBadRequest, "text is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" {
|
||||||
|
writeError(w, http.StatusBadRequest, "kind must be single|multi|text")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Points <= 0 {
|
||||||
|
req.Points = 1
|
||||||
|
}
|
||||||
|
q, err := h.repo.CreateQuestion(r.Context(), testID, req)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "create question")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, q)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TestHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := userIDFromHeader(r)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
testID, err := parseUUID(chi.URLParam(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qID, err := parseUUID(chi.URLParam(r, "questionId"))
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid question id")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
t, err := h.repo.Get(r.Context(), testID)
|
||||||
|
if err != nil {
|
||||||
|
writeRepoError(w, r, err, "get test")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if t.OwnerUserID != uid {
|
||||||
|
writeError(w, http.StatusForbidden, "only owner can delete questions")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.repo.DeleteQuestion(r.Context(), qID); err != nil {
|
||||||
|
writeRepoError(w, r, err, "delete question")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
58
internal/migrate/migrate.go
Normal file
58
internal/migrate/migrate.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// Package migrate — runner sql-миграций, идентичный candidates/tasks/booking.
|
||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Run(ctx context.Context, pool *pgxpool.Pool, migrationsDir string) error {
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(255) PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join(migrationsDir, "*.up.sql"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("glob migrations: %w", err)
|
||||||
|
}
|
||||||
|
sort.Strings(files)
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
version := strings.TrimSuffix(filepath.Base(f), ".up.sql")
|
||||||
|
var exists bool
|
||||||
|
if err := pool.QueryRow(ctx,
|
||||||
|
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`,
|
||||||
|
version).Scan(&exists); err != nil {
|
||||||
|
return fmt.Errorf("check migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
if exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sql, err := os.ReadFile(f)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
|
return fmt.Errorf("apply migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx,
|
||||||
|
`INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
|
||||||
|
return fmt.Errorf("record migration %s: %w", version, err)
|
||||||
|
}
|
||||||
|
slog.Info("applied migration", "version", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
249
internal/model/model.go
Normal file
249
internal/model/model.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
// Package model — DTO/доменные структуры learning-сервиса. Поля -> JSON
|
||||||
|
// маппятся 1-в-1 на сообщения frontend'а; cross-service id'шники (owner_user_id,
|
||||||
|
// candidate_id) — opaque UUID/int64, портал валидирует существование сам.
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// TESTS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type Test struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PassingScore *int `json:"passing_score"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
TimeLimitSec int `json:"time_limit_sec"`
|
||||||
|
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||||
|
ShowCorrectAfter bool `json:"show_correct_after"`
|
||||||
|
IsPublished bool `json:"is_published"`
|
||||||
|
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
// QuestionsCount — populate'ится через correlated subquery в List
|
||||||
|
// (не отдельной таблицей), даёт UI быстрый бейдж «N вопросов».
|
||||||
|
QuestionsCount int `json:"questions_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateTestRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PassingScore *int `json:"passing_score"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
TimeLimitSec int `json:"time_limit_sec"`
|
||||||
|
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||||
|
ShowCorrectAfter bool `json:"show_correct_after"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTestRequest — все поля nullable, обновляем только заполненные
|
||||||
|
// (паттерн COALESCE($X, current) или conditional UPDATE).
|
||||||
|
type UpdateTestRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
PassingScore *int `json:"passing_score"`
|
||||||
|
MaxAttempts *int `json:"max_attempts"`
|
||||||
|
TimeLimitSec *int `json:"time_limit_sec"`
|
||||||
|
ShuffleQuestions *bool `json:"shuffle_questions"`
|
||||||
|
ShowCorrectAfter *bool `json:"show_correct_after"`
|
||||||
|
IsPublished *bool `json:"is_published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Question struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TestID uuid.UUID `json:"test_id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Kind string `json:"kind"` // single | multi | text
|
||||||
|
Text string `json:"text"`
|
||||||
|
Points int `json:"points"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
Answers []Answer `json:"answers,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Answer struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
QuestionID uuid.UUID `json:"question_id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
// IsCorrect — фильтруется handler'ом при выдаче респонденту: показываем
|
||||||
|
// только после сабмита (если show_correct_after) или владельцу теста.
|
||||||
|
IsCorrect bool `json:"is_correct,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateQuestionRequest struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
Points int `json:"points"`
|
||||||
|
Explanation string `json:"explanation"`
|
||||||
|
Answers []CreateAnswerRequest `json:"answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAnswerRequest struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
IsCorrect bool `json:"is_correct"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ATTEMPTS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type Attempt struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
TestID uuid.UUID `json:"test_id"`
|
||||||
|
UserID *uuid.UUID `json:"user_id"`
|
||||||
|
PublicTokenID *uuid.UUID `json:"public_token_id"`
|
||||||
|
CandidateID *int64 `json:"candidate_id"`
|
||||||
|
Status string `json:"status"` // in_progress | submitted | graded | expired
|
||||||
|
Score *int `json:"score"`
|
||||||
|
MaxScore *int `json:"max_score"`
|
||||||
|
Passed *bool `json:"passed"`
|
||||||
|
RespondentName string `json:"respondent_name"`
|
||||||
|
RespondentEmail string `json:"respondent_email"`
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
SubmittedAt *time.Time `json:"submitted_at"`
|
||||||
|
GradedAt *time.Time `json:"graded_at"`
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
UserAgent string `json:"user_agent,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitAttemptRequest — отправка ответов. AttemptAnswers идут одной
|
||||||
|
// пачкой: для каждого вопроса либо single/multi (по id'шникам), либо text.
|
||||||
|
type SubmitAttemptRequest struct {
|
||||||
|
Answers []SubmitAnswer `json:"answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubmitAnswer struct {
|
||||||
|
QuestionID uuid.UUID `json:"question_id"`
|
||||||
|
AnswerID *uuid.UUID `json:"answer_id,omitempty"` // single
|
||||||
|
AnswerIDs []uuid.UUID `json:"answer_ids,omitempty"` // multi
|
||||||
|
Text string `json:"text,omitempty"` // text
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// COURSES
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type Course struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
CoverImageKey string `json:"cover_image_key"`
|
||||||
|
IsPublished bool `json:"is_published"`
|
||||||
|
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
LessonsCount int `json:"lessons_count,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateCourseRequest struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateCourseRequest struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Slug *string `json:"slug"`
|
||||||
|
CoverImageKey *string `json:"cover_image_key"`
|
||||||
|
IsPublished *bool `json:"is_published"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Lesson struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
CourseID uuid.UUID `json:"course_id"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Markdown string `json:"markdown"`
|
||||||
|
VideoKey string `json:"video_key"`
|
||||||
|
VideoDurationSec int `json:"video_duration_sec"`
|
||||||
|
TestID *uuid.UUID `json:"test_id"`
|
||||||
|
IsRequired bool `json:"is_required"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateLessonRequest struct {
|
||||||
|
Position int `json:"position"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Markdown string `json:"markdown"`
|
||||||
|
TestID *uuid.UUID `json:"test_id"`
|
||||||
|
IsRequired *bool `json:"is_required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateLessonRequest struct {
|
||||||
|
Position *int `json:"position"`
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Markdown *string `json:"markdown"`
|
||||||
|
VideoKey *string `json:"video_key"`
|
||||||
|
VideoDurationSec *int `json:"video_duration_sec"`
|
||||||
|
TestID *uuid.UUID `json:"test_id"`
|
||||||
|
IsRequired *bool `json:"is_required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// ACCESS GRANTS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type AccessGrant struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
ResourceType string `json:"resource_type"` // test | course
|
||||||
|
ResourceID uuid.UUID `json:"resource_id"`
|
||||||
|
SubjectType string `json:"subject_type"` // user | role | department | position | public
|
||||||
|
SubjectID *uuid.UUID `json:"subject_id"`
|
||||||
|
CanManage bool `json:"can_manage"`
|
||||||
|
GrantedBy uuid.UUID `json:"granted_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateAccessGrantRequest struct {
|
||||||
|
SubjectType string `json:"subject_type"`
|
||||||
|
SubjectID *uuid.UUID `json:"subject_id"`
|
||||||
|
CanManage bool `json:"can_manage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// PUBLIC TOKENS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
type PublicToken struct {
|
||||||
|
ID uuid.UUID `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID uuid.UUID `json:"resource_id"`
|
||||||
|
IntendedEmail string `json:"intended_email"`
|
||||||
|
CandidateID *int64 `json:"candidate_id"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
UsedAttempts int `json:"used_attempts"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
OpenedAt *time.Time `json:"opened_at"`
|
||||||
|
UsedAt *time.Time `json:"used_at"`
|
||||||
|
RevokedAt *time.Time `json:"revoked_at"`
|
||||||
|
CreatedBy uuid.UUID `json:"created_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePublicTokenRequest struct {
|
||||||
|
ResourceType string `json:"resource_type"`
|
||||||
|
ResourceID uuid.UUID `json:"resource_id"`
|
||||||
|
IntendedEmail string `json:"intended_email"`
|
||||||
|
CandidateID *int64 `json:"candidate_id"`
|
||||||
|
MaxAttempts int `json:"max_attempts"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicTokenResolveRequest — что приходит при «открытии» public-ссылки:
|
||||||
|
// email из формы, его сверяем с intended_email. UA/IP берём из request'а.
|
||||||
|
type PublicTokenResolveRequest struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
11
internal/repository/errors.go
Normal file
11
internal/repository/errors.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// ErrNotFound — ресурс не найден. Handler'ы маппят на 404.
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// ErrForbidden — у вызывающего нет прав на ресурс. Handler'ы маппят на 403.
|
||||||
|
// Используем repo-уровень для случаев когда проверка прав встроена в SQL
|
||||||
|
// (например, owner_user_id фильтрация).
|
||||||
|
var ErrForbidden = errors.New("forbidden")
|
||||||
282
internal/repository/test.go
Normal file
282
internal/repository/test.go
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"learning-service/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TestRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestRepository(pool *pgxpool.Pool) *TestRepository {
|
||||||
|
return &TestRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testCols включает correlated subquery для счётчика вопросов — UI получает
|
||||||
|
// бейджи без отдельного round-trip'а на каждый тест в списке.
|
||||||
|
const testCols = `
|
||||||
|
t.id, t.title, t.description, t.passing_score, t.max_attempts,
|
||||||
|
t.time_limit_sec, t.shuffle_questions, t.show_correct_after,
|
||||||
|
t.is_published, t.owner_user_id, t.created_at, t.updated_at,
|
||||||
|
(SELECT COUNT(*)::int FROM test_questions q WHERE q.test_id = t.id) AS questions_count
|
||||||
|
`
|
||||||
|
|
||||||
|
func scanTest(scan func(...any) error) (*model.Test, error) {
|
||||||
|
var t model.Test
|
||||||
|
if err := scan(
|
||||||
|
&t.ID, &t.Title, &t.Description, &t.PassingScore, &t.MaxAttempts,
|
||||||
|
&t.TimeLimitSec, &t.ShuffleQuestions, &t.ShowCorrectAfter,
|
||||||
|
&t.IsPublished, &t.OwnerUserID, &t.CreatedAt, &t.UpdatedAt,
|
||||||
|
&t.QuestionsCount,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List — листинг тестов. Фильтр onlyPublished исключает черновики (для не-владельца).
|
||||||
|
// ownerFilter (не nil) — показать только тесты конкретного юзера.
|
||||||
|
//
|
||||||
|
// Для гранулярного access-check'а (через access_grants) предусмотрено
|
||||||
|
// поле visibleIDs: если не nil, добавляется фильтр id = ANY($X). Это
|
||||||
|
// позволяет handler'у дёрнуть access-репо отдельно и передать id'шники
|
||||||
|
// сюда — repo не зависит от access-репо (плоский граф).
|
||||||
|
func (r *TestRepository) List(ctx context.Context, ownerFilter *uuid.UUID, onlyPublished bool, visibleIDs []uuid.UUID) ([]model.Test, error) {
|
||||||
|
conds := []string{}
|
||||||
|
args := []any{}
|
||||||
|
push := func(cond string, val any) {
|
||||||
|
args = append(args, val)
|
||||||
|
conds = append(conds, strings.Replace(cond, "?", fmt.Sprintf("$%d", len(args)), 1))
|
||||||
|
}
|
||||||
|
if ownerFilter != nil {
|
||||||
|
push("t.owner_user_id = ?", *ownerFilter)
|
||||||
|
}
|
||||||
|
if onlyPublished {
|
||||||
|
conds = append(conds, "t.is_published = TRUE")
|
||||||
|
}
|
||||||
|
if visibleIDs != nil {
|
||||||
|
// nil = без фильтра, пустой массив = «ничего не видно».
|
||||||
|
if len(visibleIDs) == 0 {
|
||||||
|
return []model.Test{}, nil
|
||||||
|
}
|
||||||
|
push("t.id = ANY(?)", visibleIDs)
|
||||||
|
}
|
||||||
|
where := ""
|
||||||
|
if len(conds) > 0 {
|
||||||
|
where = "WHERE " + strings.Join(conds, " AND ")
|
||||||
|
}
|
||||||
|
q := fmt.Sprintf(`SELECT %s FROM tests t %s ORDER BY t.updated_at DESC`, testCols, where)
|
||||||
|
rows, err := r.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []model.Test{}
|
||||||
|
for rows.Next() {
|
||||||
|
t, err := scanTest(rows.Scan)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *t)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestRepository) Get(ctx context.Context, id uuid.UUID) (*model.Test, error) {
|
||||||
|
q := fmt.Sprintf(`SELECT %s FROM tests t WHERE t.id = $1`, testCols)
|
||||||
|
t, err := scanTest(r.pool.QueryRow(ctx, q, id).Scan)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestRepository) Create(ctx context.Context, ownerID uuid.UUID, req model.CreateTestRequest) (*model.Test, error) {
|
||||||
|
var id uuid.UUID
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO tests (
|
||||||
|
title, description, passing_score, max_attempts, time_limit_sec,
|
||||||
|
shuffle_questions, show_correct_after, owner_user_id
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
|
RETURNING id`,
|
||||||
|
req.Title, req.Description, req.PassingScore, req.MaxAttempts,
|
||||||
|
req.TimeLimitSec, req.ShuffleQuestions, req.ShowCorrectAfter, ownerID,
|
||||||
|
).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestRepository) Update(ctx context.Context, id uuid.UUID, req model.UpdateTestRequest) (*model.Test, error) {
|
||||||
|
sets := []string{}
|
||||||
|
args := []any{}
|
||||||
|
add := func(col string, val any) {
|
||||||
|
args = append(args, val)
|
||||||
|
sets = append(sets, fmt.Sprintf("%s = $%d", col, len(args)))
|
||||||
|
}
|
||||||
|
if req.Title != nil {
|
||||||
|
add("title", *req.Title)
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
add("description", *req.Description)
|
||||||
|
}
|
||||||
|
if req.PassingScore != nil {
|
||||||
|
add("passing_score", *req.PassingScore)
|
||||||
|
}
|
||||||
|
if req.MaxAttempts != nil {
|
||||||
|
add("max_attempts", *req.MaxAttempts)
|
||||||
|
}
|
||||||
|
if req.TimeLimitSec != nil {
|
||||||
|
add("time_limit_sec", *req.TimeLimitSec)
|
||||||
|
}
|
||||||
|
if req.ShuffleQuestions != nil {
|
||||||
|
add("shuffle_questions", *req.ShuffleQuestions)
|
||||||
|
}
|
||||||
|
if req.ShowCorrectAfter != nil {
|
||||||
|
add("show_correct_after", *req.ShowCorrectAfter)
|
||||||
|
}
|
||||||
|
if req.IsPublished != nil {
|
||||||
|
add("is_published", *req.IsPublished)
|
||||||
|
}
|
||||||
|
if len(sets) == 0 {
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
sets = append(sets, "updated_at = NOW()")
|
||||||
|
args = append(args, id)
|
||||||
|
q := fmt.Sprintf(`UPDATE tests SET %s WHERE id = $%d`, strings.Join(sets, ", "), len(args))
|
||||||
|
tag, err := r.pool.Exec(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return r.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestRepository) Delete(ctx context.Context, id uuid.UUID) error {
|
||||||
|
tag, err := r.pool.Exec(ctx, `DELETE FROM tests WHERE id = $1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListQuestions — все вопросы теста, плюс ответы (через отдельный запрос
|
||||||
|
// и in-memory join по question_id). Дёшево — обычно у теста 5-30 вопросов
|
||||||
|
// по 2-4 ответа = единицы сотен строк.
|
||||||
|
func (r *TestRepository) ListQuestions(ctx context.Context, testID uuid.UUID) ([]model.Question, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, test_id, position, kind, text, points, explanation, created_at
|
||||||
|
FROM test_questions
|
||||||
|
WHERE test_id = $1
|
||||||
|
ORDER BY position, created_at`, testID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
qs := []model.Question{}
|
||||||
|
qIDs := []uuid.UUID{}
|
||||||
|
for rows.Next() {
|
||||||
|
var q model.Question
|
||||||
|
if err := rows.Scan(&q.ID, &q.TestID, &q.Position, &q.Kind, &q.Text, &q.Points, &q.Explanation, &q.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
qs = append(qs, q)
|
||||||
|
qIDs = append(qIDs, q.ID)
|
||||||
|
}
|
||||||
|
if len(qs) == 0 {
|
||||||
|
return qs, nil
|
||||||
|
}
|
||||||
|
// Один батч-запрос на все ответы.
|
||||||
|
aRows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT id, question_id, position, text, is_correct, created_at
|
||||||
|
FROM test_answers
|
||||||
|
WHERE question_id = ANY($1)
|
||||||
|
ORDER BY position, created_at`, qIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer aRows.Close()
|
||||||
|
byQ := map[uuid.UUID][]model.Answer{}
|
||||||
|
for aRows.Next() {
|
||||||
|
var a model.Answer
|
||||||
|
if err := aRows.Scan(&a.ID, &a.QuestionID, &a.Position, &a.Text, &a.IsCorrect, &a.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byQ[a.QuestionID] = append(byQ[a.QuestionID], a)
|
||||||
|
}
|
||||||
|
for i := range qs {
|
||||||
|
qs[i].Answers = byQ[qs[i].ID]
|
||||||
|
}
|
||||||
|
return qs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateQuestion — транзакционно создаёт вопрос + ответы (отдельный INSERT'ом
|
||||||
|
// на каждый, без bulk — у теста типично 2-6 ответов на вопрос).
|
||||||
|
func (r *TestRepository) CreateQuestion(ctx context.Context, testID uuid.UUID, req model.CreateQuestionRequest) (*model.Question, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
|
var qID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO test_questions (test_id, position, kind, text, points, explanation)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
|
||||||
|
testID, req.Position, req.Kind, req.Text, req.Points, req.Explanation,
|
||||||
|
).Scan(&qID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, a := range req.Answers {
|
||||||
|
if _, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO test_answers (question_id, position, text, is_correct)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
qID, a.Position, a.Text, a.IsCorrect); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Перечитываем целиком, чтобы вернуть и вопрос, и ответы.
|
||||||
|
all, err := r.ListQuestions(ctx, testID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, q := range all {
|
||||||
|
if q.ID == qID {
|
||||||
|
return &q, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *TestRepository) DeleteQuestion(ctx context.Context, questionID uuid.UUID) error {
|
||||||
|
tag, err := r.pool.Exec(ctx, `DELETE FROM test_questions WHERE id = $1`, questionID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
22
k8s/configmap.yaml
Normal file
22
k8s/configmap.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: learning-config
|
||||||
|
namespace: learning
|
||||||
|
data:
|
||||||
|
DATABASE_URL: "postgres://learning:learning@postgres.learning.svc.cluster.local:5432/learning?sslmode=disable"
|
||||||
|
SERVER_PORT: "3001"
|
||||||
|
# PORTAL_URL — для in-app уведомлений HR'у когда кандидат проходит тест,
|
||||||
|
# сотруднику когда ему назначили курс. Пустой = notify-вызовы no-op'ятся.
|
||||||
|
PORTAL_URL: "http://portal-server.portal.svc.cluster.local"
|
||||||
|
# PUBLIC_BASE_URL — внешний origin для public-ссылок кандидатам.
|
||||||
|
# Сервис не делает редиректов сам — он только подставляет origin
|
||||||
|
# в URL'и при создании public_token (для копи-пасты в email-шаблон).
|
||||||
|
PUBLIC_BASE_URL: "https://portal.estateliga.work"
|
||||||
|
# MinIO для видео-уроков. Bucket'ы создаются вручную (как для telephony-
|
||||||
|
# records). UseSSL=false внутри кластера.
|
||||||
|
MINIO_ENDPOINT: "minio.minio.svc.cluster.local:9000"
|
||||||
|
MINIO_BUCKET: "learning-videos"
|
||||||
|
MINIO_USE_SSL: "false"
|
||||||
|
REDIS_ADDR: "redis.redis.svc.cluster.local:6379"
|
||||||
|
REDIS_DB: "0"
|
||||||
12
k8s/kustomization.yaml
Normal file
12
k8s/kustomization.yaml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
|
||||||
|
namespace: learning
|
||||||
|
|
||||||
|
resources:
|
||||||
|
- namespace.yaml
|
||||||
|
- configmap.yaml
|
||||||
|
- secrets.yaml
|
||||||
|
- postgres.yaml
|
||||||
|
- server-deployment.yaml
|
||||||
|
- server-service.yaml
|
||||||
4
k8s/namespace.yaml
Normal file
4
k8s/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: learning
|
||||||
65
k8s/postgres.yaml
Normal file
65
k8s/postgres.yaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: learning
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: postgres
|
||||||
|
ports:
|
||||||
|
- port: 5432
|
||||||
|
targetPort: 5432
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: StatefulSet
|
||||||
|
metadata:
|
||||||
|
name: postgres
|
||||||
|
namespace: learning
|
||||||
|
spec:
|
||||||
|
serviceName: postgres
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: postgres
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: postgres
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16-alpine
|
||||||
|
ports:
|
||||||
|
- containerPort: 5432
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: postgres-secret
|
||||||
|
volumeMounts:
|
||||||
|
- name: pgdata
|
||||||
|
mountPath: /var/lib/postgresql/data
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
|
livenessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "learning", "-d", "learning"]
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: ["pg_isready", "-U", "learning", "-d", "learning"]
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
volumeClaimTemplates:
|
||||||
|
- metadata:
|
||||||
|
name: pgdata
|
||||||
|
spec:
|
||||||
|
accessModes: ["ReadWriteOnce"]
|
||||||
|
storageClassName: local-path
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 5Gi
|
||||||
26
k8s/secrets.yaml
Normal file
26
k8s/secrets.yaml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# SECRETS — в открытом виде намеренно, репо приватное в gitea.estateliga.work.
|
||||||
|
# INTERNAL_API_KEY — общий ключ с порталом (X-Internal-Key, constant-time
|
||||||
|
# сравнение). MINIO_ACCESS_KEY/SECRET_KEY — учётка с правом на bucket
|
||||||
|
# learning-videos (создать через MinIO console, scope = read/write).
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: learning-secrets
|
||||||
|
namespace: learning
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
PORTAL_INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
|
INTERNAL_API_KEY: "36fe89ed40c01fdc54d3cf4e3fcacc8751dc456a4a1acd394e9fed48257c5734"
|
||||||
|
MINIO_ACCESS_KEY: "learning-svc"
|
||||||
|
MINIO_SECRET_KEY: "REPLACE_AFTER_FIRST_DEPLOY"
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: postgres-secret
|
||||||
|
namespace: learning
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
POSTGRES_USER: learning
|
||||||
|
POSTGRES_PASSWORD: learning
|
||||||
|
POSTGRES_DB: learning
|
||||||
87
k8s/server-deployment.yaml
Normal file
87
k8s/server-deployment.yaml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: learning-server
|
||||||
|
namespace: learning
|
||||||
|
spec:
|
||||||
|
replicas: 2
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: learning-server
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: learning-server
|
||||||
|
spec:
|
||||||
|
terminationGracePeriodSeconds: 15
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
fsGroup: 65532
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: learning-server
|
||||||
|
image: localhost:30300/admin/learning-server:latest
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
ports:
|
||||||
|
- containerPort: 3001
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: learning-config
|
||||||
|
- secretRef:
|
||||||
|
name: learning-secrets
|
||||||
|
env:
|
||||||
|
- name: POD_NAME
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.name
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 3001
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 30
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /healthz
|
||||||
|
port: 3001
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /readyz
|
||||||
|
port: 3001
|
||||||
|
periodSeconds: 5
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
# Видео-стрим может локально жевать память, потому чуть выше.
|
||||||
|
cpu: 300m
|
||||||
|
memory: 384Mi
|
||||||
|
---
|
||||||
|
apiVersion: autoscaling/v2
|
||||||
|
kind: HorizontalPodAutoscaler
|
||||||
|
metadata:
|
||||||
|
name: learning-server
|
||||||
|
namespace: learning
|
||||||
|
spec:
|
||||||
|
scaleTargetRef:
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
name: learning-server
|
||||||
|
minReplicas: 2
|
||||||
|
maxReplicas: 5
|
||||||
|
metrics:
|
||||||
|
- type: Resource
|
||||||
|
resource:
|
||||||
|
name: cpu
|
||||||
|
target:
|
||||||
|
type: Utilization
|
||||||
|
averageUtilization: 70
|
||||||
18
k8s/server-service.yaml
Normal file
18
k8s/server-service.yaml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: learning-server
|
||||||
|
namespace: learning
|
||||||
|
annotations:
|
||||||
|
portal.estateliga.work/enabled: "true"
|
||||||
|
portal.estateliga.work/name: "Обучение"
|
||||||
|
portal.estateliga.work/description: "Тесты, курсы, видео-уроки, аттестация"
|
||||||
|
portal.estateliga.work/icon: "book"
|
||||||
|
portal.estateliga.work/path: "/api/learning"
|
||||||
|
portal.estateliga.work/code: "learning"
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: learning-server
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 3001
|
||||||
12
migrations/001_init.down.sql
Normal file
12
migrations/001_init.down.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
BEGIN;
|
||||||
|
DROP TABLE IF EXISTS public_tokens;
|
||||||
|
DROP TABLE IF EXISTS access_grants;
|
||||||
|
DROP TABLE IF EXISTS lesson_progress;
|
||||||
|
DROP TABLE IF EXISTS lessons;
|
||||||
|
DROP TABLE IF EXISTS courses;
|
||||||
|
DROP TABLE IF EXISTS test_attempt_answers;
|
||||||
|
DROP TABLE IF EXISTS test_attempts;
|
||||||
|
DROP TABLE IF EXISTS test_answers;
|
||||||
|
DROP TABLE IF EXISTS test_questions;
|
||||||
|
DROP TABLE IF EXISTS tests;
|
||||||
|
COMMIT;
|
||||||
290
migrations/001_init.up.sql
Normal file
290
migrations/001_init.up.sql
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
-- learning-service: первая миграция. Создаёт схему MVP:
|
||||||
|
-- тесты + вопросы + ответы + попытки прохождения
|
||||||
|
-- курсы + уроки (видео + markdown + опциональный тест в конце)
|
||||||
|
-- access_grants — гранулярные доступы (user / role / department / position)
|
||||||
|
-- public_tokens — одноразовые ссылки для кандидатов с email-проверкой
|
||||||
|
--
|
||||||
|
-- Cross-service ссылки (subject_id, candidate_id, owner_user_id) хранятся
|
||||||
|
-- как UUID без FK — portal/candidates живут в других БД. Целостность
|
||||||
|
-- гарантируется бизнес-логикой (валидация при создании).
|
||||||
|
--
|
||||||
|
-- 1 файл вместо разнесения по фичам — на старте проще читать как единое
|
||||||
|
-- целое; начнётся эволюция — разнесём.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TESTS
|
||||||
|
-- ============================================================
|
||||||
|
-- Тест — самостоятельная единица для прохождения. Может быть прикреплён
|
||||||
|
-- к уроку курса (lessons.test_id) или существовать отдельно (для public-
|
||||||
|
-- ссылок кандидатам, ad-hoc проверок и т.п.).
|
||||||
|
CREATE TABLE tests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
-- passing_score: процент правильных от max, 0..100. 70 = по умолчанию
|
||||||
|
-- классическая «3+». Если null — авто-зачёт не рассчитываем,
|
||||||
|
-- HR смотрит результат вручную.
|
||||||
|
passing_score INT NULL CHECK (passing_score IS NULL OR (passing_score >= 0 AND passing_score <= 100)),
|
||||||
|
-- max_attempts: 0 = без лимита, N > 0 — попытки ограничены.
|
||||||
|
max_attempts INT NOT NULL DEFAULT 0 CHECK (max_attempts >= 0),
|
||||||
|
-- time_limit_sec: 0 = без таймера; иначе UI ставит обратный отсчёт.
|
||||||
|
time_limit_sec INT NOT NULL DEFAULT 0 CHECK (time_limit_sec >= 0),
|
||||||
|
-- shuffle_questions: если true, UI каждой попытке отдаёт вопросы в
|
||||||
|
-- случайном порядке. Серверный select по test_questions.position
|
||||||
|
-- остаётся; шафлинг на клиенте.
|
||||||
|
shuffle_questions BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- show_correct_after: показывать правильные ответы по завершении
|
||||||
|
-- (учебные тесты — да; аттестационные — обычно нет).
|
||||||
|
show_correct_after BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
-- is_published — черновики не видны никому кроме owner; published
|
||||||
|
-- видны всем, кому есть access_grant.
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
owner_user_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_tests_owner ON tests(owner_user_id);
|
||||||
|
CREATE INDEX idx_tests_published ON tests(is_published) WHERE is_published = TRUE;
|
||||||
|
|
||||||
|
-- Вопрос теста. kind определяет UI-рендер и логику авто-оценки.
|
||||||
|
-- single — radio-buttons, ровно один is_correct;
|
||||||
|
-- multi — checkboxes, может быть >=1 is_correct;
|
||||||
|
-- text — свободный ввод, оценка вручную HR'ом (auto_grade=false).
|
||||||
|
CREATE TABLE test_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
|
||||||
|
-- position: явный порядок (drag&drop в UI выставит). Уникальность
|
||||||
|
-- НЕ обеспечиваем (двигание через UPDATE батчем — было бы много
|
||||||
|
-- констрейнт-flip'ов; на дубли клиент не натолкнётся в норме).
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'single'
|
||||||
|
CHECK (kind IN ('single','multi','text')),
|
||||||
|
-- text — текст вопроса; markdown допустим, фронт рендерит.
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
-- points: вклад в общий счёт. 1 по умолчанию; для «бонусных» можно 2-3.
|
||||||
|
points INT NOT NULL DEFAULT 1 CHECK (points > 0),
|
||||||
|
-- explanation: показывается после ответа (если show_correct_after).
|
||||||
|
explanation TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_test_questions_test ON test_questions(test_id, position);
|
||||||
|
|
||||||
|
-- Вариант ответа. Для kind=text — не используется (NULL=correct + sample_answer
|
||||||
|
-- в самом вопросе). Для single/multi — варианты с is_correct-флагом.
|
||||||
|
CREATE TABLE test_answers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
is_correct BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_test_answers_question ON test_answers(question_id, position);
|
||||||
|
|
||||||
|
-- Попытка прохождения. Один из {user_id, public_token_id} обязателен —
|
||||||
|
-- либо это сотрудник под портальным логином, либо кандидат по public-токену.
|
||||||
|
-- candidate_id заполняется когда токен был выпущен под конкретного кандидата
|
||||||
|
-- (см. public_tokens.candidate_id); удобно для join'ов с candidates-сервисом.
|
||||||
|
CREATE TABLE test_attempts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
test_id UUID NOT NULL REFERENCES tests(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NULL,
|
||||||
|
public_token_id UUID NULL,
|
||||||
|
candidate_id BIGINT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'in_progress'
|
||||||
|
CHECK (status IN ('in_progress','submitted','graded','expired')),
|
||||||
|
-- score: суммарные баллы за правильные ответы; max_score = sum points
|
||||||
|
-- по test_questions на момент сабмита (фиксируем для read-only результата).
|
||||||
|
score INT NULL,
|
||||||
|
max_score INT NULL,
|
||||||
|
-- passed: рассчитывается при сабмите если у теста passing_score != NULL.
|
||||||
|
passed BOOLEAN NULL,
|
||||||
|
-- Денормализованный snapshot ФИО респондента для public-ссылок:
|
||||||
|
-- кандидат заполняет в форме перед стартом, мы сохраняем, чтобы HR видел
|
||||||
|
-- кто проходил (без join'а с candidates).
|
||||||
|
respondent_name TEXT NOT NULL DEFAULT '',
|
||||||
|
respondent_email TEXT NOT NULL DEFAULT '',
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
submitted_at TIMESTAMPTZ NULL,
|
||||||
|
graded_at TIMESTAMPTZ NULL,
|
||||||
|
-- Метаданные клиента — для аудита подозрительных попыток (один токен,
|
||||||
|
-- разные IP'шники → возможно ссылку перешарили).
|
||||||
|
ip TEXT NOT NULL DEFAULT '',
|
||||||
|
user_agent TEXT NOT NULL DEFAULT '',
|
||||||
|
-- Гарантируем что заполнен хотя бы один из user_id / public_token_id.
|
||||||
|
CONSTRAINT test_attempts_subject_required CHECK (
|
||||||
|
user_id IS NOT NULL OR public_token_id IS NOT NULL
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_test_attempts_test ON test_attempts(test_id, started_at DESC);
|
||||||
|
CREATE INDEX idx_test_attempts_user ON test_attempts(user_id, started_at DESC)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_test_attempts_candidate ON test_attempts(candidate_id, started_at DESC)
|
||||||
|
WHERE candidate_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_test_attempts_token ON test_attempts(public_token_id, started_at DESC)
|
||||||
|
WHERE public_token_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Ответ пользователя на конкретный вопрос в попытке. Хранится JSONB,
|
||||||
|
-- т.к. формат зависит от kind:
|
||||||
|
-- single → {"answer_id": "uuid"}
|
||||||
|
-- multi → {"answer_ids": ["uuid", "uuid"]}
|
||||||
|
-- text → {"text": "...свободный ответ..."}
|
||||||
|
-- При сабмите attempts-handler сверяется с test_answers.is_correct и
|
||||||
|
-- считает score; для text — оставляет null до ручной оценки HR'ом.
|
||||||
|
CREATE TABLE test_attempt_answers (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
attempt_id UUID NOT NULL REFERENCES test_attempts(id) ON DELETE CASCADE,
|
||||||
|
question_id UUID NOT NULL REFERENCES test_questions(id) ON DELETE CASCADE,
|
||||||
|
payload JSONB NOT NULL,
|
||||||
|
-- correct: NULL для text (ждёт ручной оценки), true/false для auto-graded.
|
||||||
|
correct BOOLEAN NULL,
|
||||||
|
-- score: 0..question.points. Заполняется при сабмите (auto) или вручную HR'ом.
|
||||||
|
score INT NULL CHECK (score IS NULL OR score >= 0),
|
||||||
|
answered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (attempt_id, question_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- COURSES
|
||||||
|
-- ============================================================
|
||||||
|
-- Курс = упорядоченный список уроков. Уроки могут содержать видео + текст
|
||||||
|
-- и ОПЦИОНАЛЬНО прикреплённый тест (test_id NULL — урок без проверки).
|
||||||
|
CREATE TABLE courses (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
-- slug: короткий человекочитаемый идентификатор для URL'ей
|
||||||
|
-- (/learning/c/onboarding-2025). Уникален среди опубликованных.
|
||||||
|
slug TEXT NOT NULL,
|
||||||
|
-- cover_image_key: опциональная картинка-обложка (MinIO key).
|
||||||
|
cover_image_key TEXT NOT NULL DEFAULT '',
|
||||||
|
is_published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
owner_user_id UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
UNIQUE (slug)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_courses_owner ON courses(owner_user_id);
|
||||||
|
CREATE INDEX idx_courses_published ON courses(is_published) WHERE is_published = TRUE;
|
||||||
|
|
||||||
|
-- Урок. Внутри — markdown-тело (всегда есть, может быть пустым) + видео
|
||||||
|
-- (опционально, через MinIO key) + тест в конце (опционально, FK на tests).
|
||||||
|
CREATE TABLE lessons (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
|
||||||
|
position INT NOT NULL DEFAULT 0,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
-- markdown: основной контент. Можно использовать сам по себе (без видео).
|
||||||
|
markdown TEXT NOT NULL DEFAULT '',
|
||||||
|
-- video_key: ключ объекта в MinIO bucket (см. config.MinIOBucket).
|
||||||
|
-- При удалении урока физический файл НЕ удаляется — отдельный сборщик
|
||||||
|
-- мусора по «висящим» ключам, чтобы случайный delete не уничтожил видео.
|
||||||
|
video_key TEXT NOT NULL DEFAULT '',
|
||||||
|
video_duration_sec INT NOT NULL DEFAULT 0,
|
||||||
|
-- test_id: связь с тестом. На уровне урока — для проверки усвоения.
|
||||||
|
test_id UUID NULL REFERENCES tests(id) ON DELETE SET NULL,
|
||||||
|
-- is_required: курс не считается пройденным пока этот урок не закрыт.
|
||||||
|
-- В MVP «закрыт» = выставлен flag в progress-таблице (см. ниже).
|
||||||
|
is_required BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_lessons_course ON lessons(course_id, position);
|
||||||
|
|
||||||
|
-- Прогресс по урокам. Один ряд = один пользователь × один урок.
|
||||||
|
-- viewed_at = просмотрел видео/прочитал markdown; completed_at = плюс
|
||||||
|
-- сдал прикреплённый тест (если он был). Для уроков без теста completed_at
|
||||||
|
-- ставится одновременно с viewed_at (handler сам отметит).
|
||||||
|
CREATE TABLE lesson_progress (
|
||||||
|
lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL,
|
||||||
|
viewed_at TIMESTAMPTZ NULL,
|
||||||
|
completed_at TIMESTAMPTZ NULL,
|
||||||
|
PRIMARY KEY (lesson_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_lesson_progress_user ON lesson_progress(user_id, completed_at DESC);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- ACCESS GRANTS
|
||||||
|
-- ============================================================
|
||||||
|
-- Гранулярный доступ к ресурсу. Один ряд = «кому что разрешено».
|
||||||
|
--
|
||||||
|
-- resource_type: 'test' | 'course'
|
||||||
|
-- subject_type: 'user' | 'role' | 'department' | 'position' | 'public'
|
||||||
|
-- - user: subject_id = portal user_id (UUID)
|
||||||
|
-- - role: subject_id = portal role_id (UUID) — все носители роли получают доступ
|
||||||
|
-- - department: subject_id = portal department_id (UUID) — все из отдела
|
||||||
|
-- - position: subject_id = portal position_id (UUID) — все на этой должности
|
||||||
|
-- - public: subject_id = NULL — anyone с действующим public-токеном
|
||||||
|
--
|
||||||
|
-- can_manage: если true — subject может редактировать ресурс (owner + admins
|
||||||
|
-- получают это автоматически без grant'а; здесь — для «соавторов»).
|
||||||
|
CREATE TABLE access_grants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
|
||||||
|
resource_id UUID NOT NULL,
|
||||||
|
subject_type TEXT NOT NULL CHECK (subject_type IN ('user','role','department','position','public')),
|
||||||
|
subject_id UUID NULL,
|
||||||
|
can_manage BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
granted_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
-- public: subject_id обязательно NULL; для остальных типов — обязательно заполнено.
|
||||||
|
CONSTRAINT access_grants_subject_id_match CHECK (
|
||||||
|
(subject_type = 'public' AND subject_id IS NULL) OR
|
||||||
|
(subject_type <> 'public' AND subject_id IS NOT NULL)
|
||||||
|
),
|
||||||
|
-- Уникальность: не можем выдать два одинаковых грантa.
|
||||||
|
UNIQUE (resource_type, resource_id, subject_type, subject_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_access_resource ON access_grants(resource_type, resource_id);
|
||||||
|
CREATE INDEX idx_access_subject ON access_grants(subject_type, subject_id)
|
||||||
|
WHERE subject_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PUBLIC TOKENS
|
||||||
|
-- ============================================================
|
||||||
|
-- Одноразовая ссылка для кандидата. HR создаёт токен на конкретный тест
|
||||||
|
-- или курс, привязывает к email + опционально candidate_id, отправляет
|
||||||
|
-- ссылку <PUBLIC_BASE>/public/learning/<resource_type>/<token>.
|
||||||
|
--
|
||||||
|
-- При открытии:
|
||||||
|
-- 1. Фронт спрашивает email
|
||||||
|
-- 2. Бэк сверяет с intended_email (case-insensitive). Не совпало → 403.
|
||||||
|
-- 3. Создаём test_attempt с public_token_id, привязываем opened_at.
|
||||||
|
-- 4. Юзер проходит, отправляет — used_at заполняется.
|
||||||
|
--
|
||||||
|
-- max_attempts > 1 разрешает несколько прохождений по одной ссылке (например,
|
||||||
|
-- для tutorial-теста). По умолчанию 1.
|
||||||
|
CREATE TABLE public_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
-- token: длинная случайная строка; ходит в URL. Делаем поле UNIQUE,
|
||||||
|
-- чтобы повторно открыть ссылку и проверить, что она ещё действует.
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
resource_type TEXT NOT NULL CHECK (resource_type IN ('test','course')),
|
||||||
|
resource_id UUID NOT NULL,
|
||||||
|
intended_email TEXT NOT NULL,
|
||||||
|
-- candidate_id: если HR создавал ссылку из карточки candidate'а —
|
||||||
|
-- сохраняем для join'а результатов с candidates-сервисом.
|
||||||
|
candidate_id BIGINT NULL,
|
||||||
|
max_attempts INT NOT NULL DEFAULT 1 CHECK (max_attempts > 0),
|
||||||
|
used_attempts INT NOT NULL DEFAULT 0,
|
||||||
|
expires_at TIMESTAMPTZ NULL,
|
||||||
|
-- opened_at: первый раз когда токен был «активирован» (открыт +
|
||||||
|
-- прошёл email-проверку). Для аудита.
|
||||||
|
opened_at TIMESTAMPTZ NULL,
|
||||||
|
-- used_at: первый submitted attempt. После этого, если max_attempts=1,
|
||||||
|
-- ссылка не открывается заново.
|
||||||
|
used_at TIMESTAMPTZ NULL,
|
||||||
|
-- revoked_at: HR может вручную отозвать ссылку до использования.
|
||||||
|
revoked_at TIMESTAMPTZ NULL,
|
||||||
|
created_by UUID NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_public_tokens_resource ON public_tokens(resource_type, resource_id);
|
||||||
|
CREATE INDEX idx_public_tokens_candidate ON public_tokens(candidate_id)
|
||||||
|
WHERE candidate_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_public_tokens_email ON public_tokens(LOWER(intended_email));
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
Reference in New Issue
Block a user