203 lines
8.3 KiB
Go
203 lines
8.3 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"
|
||
|
||
commonaudit "gitea.estateliga.work/admin/portal-common/audit"
|
||
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.ConnectWithRetry(ctx, cfg.DatabaseURL, 2*time.Minute)
|
||
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)
|
||
auditClient := commonaudit.NewClient(cfg.PortalURL, cfg.InternalAPIKey)
|
||
if !auditClient.Enabled() {
|
||
slog.Warn("portal audit client disabled — business audit events выключены",
|
||
"portal_url_set", cfg.PortalURL != "", "portal_key_set", cfg.InternalAPIKey != "")
|
||
}
|
||
|
||
healthH := handler.NewHealthHandler(pool, store)
|
||
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.Get("/health/detail", healthH.Detail)
|
||
|
||
r.Route("/api", func(r chi.Router) {
|
||
r.Use(commonmw.InternalAuth(cfg.InternalAPIKey))
|
||
r.Use(handler.NewAuditMiddleware(auditClient).Middleware)
|
||
|
||
// 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")
|
||
}
|