Files
learning/cmd/server/main.go
Ilya 89abcc1718
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 27s
feat(access): гранулярные доступы (access_grants)
AccessGrantRepository:
- CRUD (List/Create/Get/Delete) с UPSERT на (resource, subject) для
  идемпотентности повторных grant'ов.
- ResolveVisibleResourceIDs(viewer ViewerContext) — для данного юзера
  возвращает DISTINCT set resource_id'шников, выданных через любой из
  subject_type: 'public' OR 'user'==viewer OR 'role'∈viewer.RoleIDs OR
  'department'==viewer.DepartmentID OR 'position'==viewer.PositionID.
  ViewerContext собирается из X-User-Roles/Department-Id/Position-Id
  headers'ов (portal-gateway прокидывает после JWT-валидации).

AccessGrantHandler:
- GET    /access/{resourceType}/{resourceId} — list (owner-only).
- POST   /access/{resourceType}/{resourceId} — выдать (UPSERT).
- DELETE /access/{resourceType}/{resourceId}/grants/{grantId} —
  отозвать. resourceType/Id дублируются в URL'е для cross-check'а
  чтобы owner ресурса A не мог удалить grant ресурса B по grantId.

Интеграция в List'ах:
- TestHandler.List: ?mine=true работает как было; без mine видны
  published + дозалив unpublished, выданных через access_grants.
- CourseHandler.List: то же поведение зеркально.
Семантика union'а: «published all + grant-only». Это backward-compat
(старые published продолжают быть видны всем), при этом HR может
явно выдать draft-ресурс конкретному юзеру/роли без публикации.

helpers.go: viewerContextFromHeaders — парсит X-User-Roles (CSV),
X-User-Department-Id, X-User-Position-Id; невалидные/пустые → default.

Wire-up: accessRepo внедрён в Test/Course handler'ы; accessH
зарегистрирован вместо предыдущей 501-заглушки.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 01:17:42 +03:00

204 lines
8.4 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"
"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 — следующая итерация"}`))
}