init: learning-service skeleton

Микросервис обучения портала: тесты, курсы, видео-уроки, доступы,
public-ссылки для кандидатов с email-валидацией.

В этой итерации:
- Skeleton (config, migrate, main, health) по паттерну tasks/candidates
- Migration 001_init: 10 таблиц (tests/questions/answers/attempts/
  attempt_answers + courses/lessons/lesson_progress + access_grants +
  public_tokens) с подробными комментариями why
- Tests: полный CRUD + вопросы/ответы; non-owner'у is_correct и
  explanation скрываются в выдаче
- Заглушки 501 для attempts / courses / lessons / video-stream /
  access / public-tokens — следующие итерации
- k8s: namespace, configmap, secrets, postgres, deployment с HPA,
  service с portal-discovery annotations
- Dockerfile, Makefile, .gitignore

См. README.md для полного списка отложенного и инструкций запуска.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 22:43:37 +03:00
commit 62519081e7
24 changed files with 1915 additions and 0 deletions

133
cmd/server/main.go Normal file
View File

@@ -0,0 +1,133 @@
// 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)
healthH := handler.NewHealthHandler(pool)
testH := handler.NewTestHandler(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)
// Заглушки на следующие итерации. Возвращают 501 — фронт может
// рендерить «функция в разработке», если случайно дёрнет.
r.HandleFunc("/tests/{id}/attempts", notImplemented)
r.HandleFunc("/attempts/{id}", notImplemented)
r.HandleFunc("/attempts/{id}/submit", notImplemented)
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 — следующая итерация"}`))
}