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:
34
internal/handler/health.go
Normal file
34
internal/handler/health.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type HealthHandler struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler {
|
||||
return &HealthHandler{pool: pool}
|
||||
}
|
||||
|
||||
// Healthz — liveness. Не дёргает БД; жив если процесс отвечает.
|
||||
func (h *HealthHandler) Healthz(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// Readyz — readiness. Один Ping к БД с таймаутом — если не отвечает,
|
||||
// k8s выкидывает pod из service-балансира.
|
||||
func (h *HealthHandler) Readyz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := h.pool.Ping(ctx); err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "db not ready")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
|
||||
}
|
||||
63
internal/handler/helpers.go
Normal file
63
internal/handler/helpers.go
Normal 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
|
||||
}
|
||||
260
internal/handler/test.go
Normal file
260
internal/handler/test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"learning-service/internal/model"
|
||||
"learning-service/internal/repository"
|
||||
)
|
||||
|
||||
type TestHandler struct {
|
||||
repo *repository.TestRepository
|
||||
}
|
||||
|
||||
func NewTestHandler(repo *repository.TestRepository) *TestHandler {
|
||||
return &TestHandler{repo: repo}
|
||||
}
|
||||
|
||||
// List — GET /tests. Параметры:
|
||||
// ?mine=true — только мои (owner_user_id = X-User-Id);
|
||||
// без mine — published тесты (для прохождения).
|
||||
//
|
||||
// MVP: ещё не подключён access_grants-фильтр; до этого момента «published»
|
||||
// = «всем видно». Следующая итерация: handler через AccessRepository
|
||||
// получит visibleIDs и передаст в repo.List.
|
||||
func (h *TestHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
mine := r.URL.Query().Get("mine") == "true"
|
||||
var ownerFilter *uuid.UUID
|
||||
if mine {
|
||||
ownerFilter = &uid
|
||||
}
|
||||
tests, err := h.repo.List(r.Context(), ownerFilter, !mine, nil)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "list tests")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": tests})
|
||||
}
|
||||
|
||||
func (h *TestHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
t, err := h.repo.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "get test")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (h *TestHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
var req model.CreateTestRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Title) == "" {
|
||||
writeError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
t, err := h.repo.Create(r.Context(), uid, req)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "create test")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, t)
|
||||
}
|
||||
|
||||
// Update / Delete: пока без access-проверки (только owner). Когда подключим
|
||||
// access_grants с can_manage, разрешим co-owner'ам тоже редактировать.
|
||||
func (h *TestHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
existing, err := h.repo.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "get test for update")
|
||||
return
|
||||
}
|
||||
if existing.OwnerUserID != uid {
|
||||
writeError(w, http.StatusForbidden, "only owner can edit")
|
||||
return
|
||||
}
|
||||
var req model.UpdateTestRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
t, err := h.repo.Update(r.Context(), id, req)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "update test")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, t)
|
||||
}
|
||||
|
||||
func (h *TestHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
existing, err := h.repo.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "get test for delete")
|
||||
return
|
||||
}
|
||||
if existing.OwnerUserID != uid {
|
||||
writeError(w, http.StatusForbidden, "only owner can delete")
|
||||
return
|
||||
}
|
||||
if err := h.repo.Delete(r.Context(), id); err != nil {
|
||||
writeRepoError(w, r, err, "delete test")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ListQuestions — вопросы теста с ответами. Если запросивший не владелец —
|
||||
// is_correct поле обнуляется в ответе (показывать правильные ответы до
|
||||
// сабмита нельзя — иначе тест теряет смысл).
|
||||
func (h *TestHandler) ListQuestions(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
testID, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
t, err := h.repo.Get(r.Context(), testID)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "get test")
|
||||
return
|
||||
}
|
||||
qs, err := h.repo.ListQuestions(r.Context(), testID)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "list questions")
|
||||
return
|
||||
}
|
||||
// Не-владельцу скрываем is_correct, чтобы он не подсмотрел через
|
||||
// DevTools правильные ответы до сабмита.
|
||||
if t.OwnerUserID != uid {
|
||||
for i := range qs {
|
||||
for j := range qs[i].Answers {
|
||||
qs[i].Answers[j].IsCorrect = false
|
||||
}
|
||||
// Объяснение тоже не светим — оно потенциально содержит
|
||||
// разбор правильного ответа.
|
||||
qs[i].Explanation = ""
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": qs})
|
||||
}
|
||||
|
||||
func (h *TestHandler) CreateQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
testID, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
t, err := h.repo.Get(r.Context(), testID)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "get test")
|
||||
return
|
||||
}
|
||||
if t.OwnerUserID != uid {
|
||||
writeError(w, http.StatusForbidden, "only owner can add questions")
|
||||
return
|
||||
}
|
||||
var req model.CreateQuestionRequest
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid body")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, "text is required")
|
||||
return
|
||||
}
|
||||
if req.Kind != "single" && req.Kind != "multi" && req.Kind != "text" {
|
||||
writeError(w, http.StatusBadRequest, "kind must be single|multi|text")
|
||||
return
|
||||
}
|
||||
if req.Points <= 0 {
|
||||
req.Points = 1
|
||||
}
|
||||
q, err := h.repo.CreateQuestion(r.Context(), testID, req)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "create question")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, q)
|
||||
}
|
||||
|
||||
func (h *TestHandler) DeleteQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := userIDFromHeader(r)
|
||||
if !ok {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
testID, err := parseUUID(chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
qID, err := parseUUID(chi.URLParam(r, "questionId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid question id")
|
||||
return
|
||||
}
|
||||
t, err := h.repo.Get(r.Context(), testID)
|
||||
if err != nil {
|
||||
writeRepoError(w, r, err, "get test")
|
||||
return
|
||||
}
|
||||
if t.OwnerUserID != uid {
|
||||
writeError(w, http.StatusForbidden, "only owner can delete questions")
|
||||
return
|
||||
}
|
||||
if err := h.repo.DeleteQuestion(r.Context(), qID); err != nil {
|
||||
writeRepoError(w, r, err, "delete question")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user