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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user