Files
learning/cmd/server/main.go
Ilya 350703ab83
All checks were successful
CI / test (push) Successful in 14s
Build and Deploy / build-and-deploy (push) Successful in 25s
feat(courses): CRUD курсов
Базовая работа с курсами (без уроков — добавятся в следующей итерации).

CourseRepository:
- List с тем же паттерном что TestRepository: ownerFilter +
  onlyPublished + visibleIDs (для будущего access_grants).
- Get / GetBySlug — slug нужен для public-страниц.
- Create — slugify(title) если slug не задан; collision retry до 5 раз
  (UNIQUE constraint courses_slug_key).
- Update / Delete с CASCADE на lessons.
- courseCols + lessons_count subquery, UI получает бейдж без отдельного
  запроса.

CourseHandler — стандартный набор. Гейтит owner для write/delete;
read доступен всем (внутри сервиса), portal проксирует под
service.learning.access.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:31:20 +03:00

148 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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)
courseRepo := repository.NewCourseRepository(pool)
healthH := handler.NewHealthHandler(pool)
testH := handler.NewTestHandler(testRepo)
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
courseH := handler.NewCourseHandler(courseRepo)
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 внутри теста. /reorder регистрируется ДО /{questionId},
// иначе chi заматчит {questionId} = "reorder".
r.Get("/tests/{id}/questions", testH.ListQuestions)
r.Post("/tests/{id}/questions", testH.CreateQuestion)
r.Post("/tests/{id}/questions/reorder", testH.ReorderQuestions)
r.Put("/tests/{id}/questions/{questionId}", testH.UpdateQuestion)
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)
// Courses CRUD. Lessons/video — следующая итерация.
r.Get("/courses", courseH.List)
r.Post("/courses", courseH.Create)
r.Get("/courses/{id}", courseH.Get)
r.Patch("/courses/{id}", courseH.Update)
r.Delete("/courses/{id}", courseH.Delete)
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 — следующая итерация"}`))
}