// 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" "learning-service/internal/storage" ) 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) } // MinIO для видео-уроков. Если ENV не сконфигурирован — клиент nil, // upload/stream-handler'ы возвращают 503; остальной сервис работает. store, err := storage.New(storage.Config{ Endpoint: cfg.MinIOEndpoint, AccessKey: cfg.MinIOAccessKey, SecretKey: cfg.MinIOSecretKey, Bucket: cfg.MinIOBucket, UseSSL: cfg.MinIOUseSSL, }) if err != nil { slog.Error("init storage", "error", err) os.Exit(1) } if store.Configured() { if err := store.EnsureBucket(context.Background()); err != nil { slog.Warn("ensure bucket failed", "error", err) } slog.Info("video storage enabled", "endpoint", cfg.MinIOEndpoint, "bucket", cfg.MinIOBucket) } else { slog.Warn("video storage disabled (MINIO_* env vars not set)") } testRepo := repository.NewTestRepository(pool) attemptRepo := repository.NewAttemptRepository(pool) courseRepo := repository.NewCourseRepository(pool) lessonRepo := repository.NewLessonRepository(pool) publicTokenRepo := repository.NewPublicTokenRepository(pool) accessRepo := repository.NewAccessGrantRepository(pool) healthH := handler.NewHealthHandler(pool) testH := handler.NewTestHandler(testRepo, accessRepo) attemptH := handler.NewAttemptHandler(attemptRepo, testRepo) courseH := handler.NewCourseHandler(courseRepo, accessRepo) lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store) publicTokenH := handler.NewPublicTokenHandler(publicTokenRepo, testRepo, courseRepo, attemptRepo) accessH := handler.NewAccessGrantHandler(accessRepo, testRepo, 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. 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) // Lessons CRUD. /reorder регистрируется ДО /{id}, иначе chi // заматчит {id} = "reorder". r.Get("/courses/{courseId}/lessons", lessonH.ListByCourse) r.Post("/courses/{courseId}/lessons", lessonH.Create) r.Post("/courses/{courseId}/lessons/reorder", lessonH.Reorder) // Flat-список уроков с видео для отдельной страницы «Видео-уроки». // has_video=true игнорируется — у нас только этот режим; флаг // зарезервирован для будущего фильтра «и без видео тоже». r.Get("/lessons", lessonH.ListVideos) r.Get("/lessons/{id}", lessonH.Get) r.Patch("/lessons/{id}", lessonH.Update) r.Delete("/lessons/{id}", lessonH.Delete) // Video upload/stream/delete. Upload — multipart (поле "video"), // stream — Range-aware прокси из MinIO. r.Post("/lessons/{id}/video", lessonH.UploadVideo) r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo) r.Delete("/lessons/{id}/video", lessonH.DeleteVideo) // Access grants — гранулярные доступы (user/role/department/position/public). // Управляет владелец ресурса; SubjectIDs матчатся по X-User-Roles/ // Department/Position-headers'ам, прокидываемым portal-gateway'ом. r.Get("/access/{resourceType}/{resourceId}", accessH.List) r.Post("/access/{resourceType}/{resourceId}", accessH.Create) r.Delete("/access/{resourceType}/{resourceId}/grants/{grantId}", accessH.Delete) // Public tokens — HR-side: создать ссылку для кандидата, посмотреть // список, отозвать. Сам прохождение тестa по токену — в /public ниже. r.Post("/public-tokens", publicTokenH.Create) r.Get("/public-tokens", publicTokenH.ListByResource) r.Delete("/public-tokens/{id}", publicTokenH.Revoke) }) // Public endpoints — без InternalAuth (кандидаты ходят анонимно // по token'у). Гейтят через сам token внутри handler'ов. r.Route("/public", func(r chi.Router) { // Info — лёгкий read для лэндинга (проверка валидности + title). r.Get("/learning/tokens/{token}/info", publicTokenH.PublicInfo) // Resolve — кандидат вводит email, бэк сверяет с intended_email // и создаёт attempt. Возвращает attempt_id + первое чтение теста. r.Post("/learning/tokens/{token}/resolve", publicTokenH.PublicResolve) // Attempts — read/submit с обязательным ?token=… в query. r.Get("/learning/attempts/{id}", publicTokenH.PublicGetAttempt) r.Post("/learning/attempts/{id}/submit", publicTokenH.PublicSubmit) }) 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 — следующая итерация"}`)) }