Files
learning/cmd/server/main.go
Ilya 62519081e7 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>
2026-05-25 22:43:37 +03:00

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