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

View File

@@ -0,0 +1,63 @@
package handler
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"github.com/google/uuid"
"learning-service/internal/repository"
)
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]any{"error": msg})
}
// writeRepoError — маппит repository.Err* на HTTP-коды. Для остальных
// логируем и отдаём 500 без деталей (наружу не светим внутренние ошибки).
func writeRepoError(w http.ResponseWriter, r *http.Request, err error, action string) {
switch {
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrForbidden):
writeError(w, http.StatusForbidden, "forbidden")
default:
slog.Error("repo error", "action", action, "error", err, "path", r.URL.Path)
writeError(w, http.StatusInternalServerError, "internal error")
}
}
func decodeJSON(r *http.Request, v any) error {
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
return dec.Decode(v)
}
func parseUUID(s string) (uuid.UUID, error) {
if s == "" {
return uuid.Nil, errors.New("empty uuid")
}
return uuid.Parse(s)
}
// userIDFromHeader — portal-gateway проставляет X-User-Id после валидации JWT.
// Все НЕ-internal handler'ы читают отсюда; пустой = unauthorized.
func userIDFromHeader(r *http.Request) (uuid.UUID, bool) {
v := r.Header.Get("X-User-Id")
if v == "" {
return uuid.Nil, false
}
id, err := uuid.Parse(v)
if err != nil {
return uuid.Nil, false
}
return id, true
}