AttemptRepository:
- Start: проверка max_attempts (учитывает уже использованные с этого
user_id или public_token_id), вставка in_progress'а;
- Get/ListByUser/ListByTest: чтение с per-attempt scope;
- SubmitAndGrade: транзакционно сохраняет ответы в attempt_answers
(JSONB payload + correct + score), считает итог:
single — 1 правильный → points за вопрос, иначе 0;
multi — set ответов == set is_correct=TRUE → points, иначе 0
(частичные баллы не делаем в MVP);
text — correct=NULL и score=NULL, ждут ручной оценки HR'ом.
max_score = SUM(points) по всем вопросам (не только отвеченным).
passed = NULL если у теста нет passing_score; иначе процент vs порог.
status: graded если все автогрейд'ятся; submitted если есть text.
AttemptHandler:
- POST /tests/{id}/attempts — Start (X-User-Id из portal-gateway).
Не-владелец стартует только если is_published=true.
- GET /attempts/{id} — Get с проверкой «я респондент / я владелец теста».
- POST /attempts/{id}/submit — Submit (только свою попытку).
- GET /attempts — ListMine.
- GET /tests/{id}/attempts — ListByTest (только для владельца).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
140 lines
4.8 KiB
Go
140 lines
4.8 KiB
Go
// 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)
|
||
attemptRepo := repository.NewAttemptRepository(pool)
|
||
|
||
healthH := handler.NewHealthHandler(pool)
|
||
testH := handler.NewTestHandler(testRepo)
|
||
attemptH := handler.NewAttemptHandler(attemptRepo, 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)
|
||
|
||
// Attempts: старт + получение + сабмит. Списки — отдельно
|
||
// (мои попытки vs все попытки по тесту для HR).
|
||
r.Post("/tests/{id}/attempts", attemptH.Start)
|
||
r.Get("/tests/{id}/attempts", attemptH.ListByTest)
|
||
r.Get("/attempts", attemptH.ListMine)
|
||
r.Get("/attempts/{id}", attemptH.Get)
|
||
r.Post("/attempts/{id}/submit", attemptH.Submit)
|
||
|
||
// Заглушки на следующие итерации.
|
||
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 — следующая итерация"}`))
|
||
}
|