feat(public-tokens): одноразовые ссылки для кандидатов
All checks were successful
CI / test (push) Successful in 19s
Build and Deploy / build-and-deploy (push) Successful in 28s

PublicTokenRepository:
- Create — генерирует 256-битный URL-safe токен через crypto/rand;
  intended_email нормализуется в lower-case; max_attempts<=0 → 1;
- GetByToken — поиск по URL-токену для public-endpoint'ов;
- ListByResource — все токены для теста/курса (HR-UI);
- Revoke — soft-cancel (revoked_at = NOW());
- CheckUsable — валидирует токен: revoked/expired/exhausted →
  типизированные ошибки (ErrTokenInvalid/Expired/Exhausted/Email);
- MatchEmail — case-insensitive сравнение;
- MarkOpened / IncrementUsed — для аудита и счётчика попыток.

PublicTokenHandler — два слоя:

HR (/api, под service.learning.access + owner-проверка):
- POST /public-tokens — Create;
- GET /public-tokens?resource_type=...&resource_id=... — ListByResource;
- DELETE /public-tokens/{id} — Revoke.

Public (/public, без auth):
- GET /public/learning/tokens/{token}/info — title + status
  ({valid|revoked|expired|exhausted}). IntendedEmail НЕ возвращаем,
  чтобы любой со ссылкой не узнал чей это email.
- POST /public/learning/tokens/{token}/resolve {email} — сверяет
  email с intended (case-insensitive), создаёт attempt со
  public_token_id, помечает opened_at. IncrementUsed на submit'е
  (а не resolve'е), чтобы кандидат не сжёг попытку случайным
  открытием.
- GET /public/learning/attempts/{id}?token=… — текущий attempt +
  questions (is_correct/explanation скрыты).
- POST /public/learning/attempts/{id}/submit?token=… — сабмит +
  автогрейд + IncrementUsed.

MVP поддерживает только resource_type='test'. Courses через public-
ссылку — следующая итерация (нужен view-mode без логина).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-26 00:45:49 +03:00
parent 400df0124d
commit d773999296
3 changed files with 588 additions and 7 deletions

View File

@@ -0,0 +1,362 @@
package handler
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"learning-service/internal/model"
"learning-service/internal/repository"
)
// PublicTokenHandler — два слоя:
//
// 1. HR-методы под /api (защищены X-User-Id через portal-gateway):
// Create / List / Revoke. Только владелец ресурса может создавать
// ссылки на свой тест/курс.
//
// 2. Public-методы под /public (без auth, гейтят через сам token):
// Resolve (email-check + create attempt), GetAttempt, SubmitAttempt.
// Используются на лэндинге кандидата.
type PublicTokenHandler struct {
repo *repository.PublicTokenRepository
testRepo *repository.TestRepository
courseRepo *repository.CourseRepository
attemptRepo *repository.AttemptRepository
}
func NewPublicTokenHandler(
repo *repository.PublicTokenRepository,
testRepo *repository.TestRepository,
courseRepo *repository.CourseRepository,
attemptRepo *repository.AttemptRepository,
) *PublicTokenHandler {
return &PublicTokenHandler{
repo: repo, testRepo: testRepo, courseRepo: courseRepo, attemptRepo: attemptRepo,
}
}
// ============================================================
// HR-side (/api)
// ============================================================
// authorizeResourceOwner — проверяет что X-User-Id владеет ресурсом
// (тест или курс). Если нет — 403, если ресурс не найден — 404.
func (h *PublicTokenHandler) authorizeResourceOwner(w http.ResponseWriter, r *http.Request, resourceType string, resourceID uuid.UUID) (uuid.UUID, bool) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return uuid.Nil, false
}
var owner uuid.UUID
switch resourceType {
case "test":
t, err := h.testRepo.Get(r.Context(), resourceID)
if err != nil {
writeRepoError(w, r, err, "get test")
return uuid.Nil, false
}
owner = t.OwnerUserID
case "course":
c, err := h.courseRepo.Get(r.Context(), resourceID)
if err != nil {
writeRepoError(w, r, err, "get course")
return uuid.Nil, false
}
owner = c.OwnerUserID
default:
writeError(w, http.StatusBadRequest, "resource_type must be test|course")
return uuid.Nil, false
}
if owner != uid {
writeError(w, http.StatusForbidden, "only owner can issue tokens")
return uuid.Nil, false
}
return uid, true
}
// Create — POST /public-tokens. Body: CreatePublicTokenRequest.
func (h *PublicTokenHandler) Create(w http.ResponseWriter, r *http.Request) {
var req model.CreatePublicTokenRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
uid, ok := h.authorizeResourceOwner(w, r, req.ResourceType, req.ResourceID)
if !ok {
return
}
t, err := h.repo.Create(r.Context(), uid, req)
if err != nil {
writeRepoError(w, r, err, "create public token")
return
}
writeJSON(w, http.StatusCreated, t)
}
// ListByResource — GET /public-tokens?resource_type=test&resource_id=uuid.
// Только владелец ресурса.
func (h *PublicTokenHandler) ListByResource(w http.ResponseWriter, r *http.Request) {
resourceType := r.URL.Query().Get("resource_type")
rawID := r.URL.Query().Get("resource_id")
if resourceType == "" || rawID == "" {
writeError(w, http.StatusBadRequest, "resource_type and resource_id are required")
return
}
resourceID, err := parseUUID(rawID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid resource_id")
return
}
if _, ok := h.authorizeResourceOwner(w, r, resourceType, resourceID); !ok {
return
}
items, err := h.repo.ListByResource(r.Context(), resourceType, resourceID)
if err != nil {
writeRepoError(w, r, err, "list public tokens")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// Revoke — DELETE /public-tokens/{id}. Только владелец ресурса.
func (h *PublicTokenHandler) Revoke(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 token")
return
}
if _, ok := h.authorizeResourceOwner(w, r, t.ResourceType, t.ResourceID); !ok {
return
}
if err := h.repo.Revoke(r.Context(), id); err != nil {
writeRepoError(w, r, err, "revoke token")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ============================================================
// Public-side (/public — без auth)
// ============================================================
// publicTokenStatus — что отдаём кандидату при resolve'е/info-запросе.
// Безопасные поля: тип ресурса, заголовок (для preview), shows valid'ность.
// IntendedEmail не возвращаем — иначе любой со ссылкой узнает на чей email
// она выпущена.
type publicTokenStatus struct {
Token string `json:"token"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ResourceTitle string `json:"resource_title"`
Status string `json:"status"` // valid | expired | revoked | exhausted
}
// Info — GET /public/learning/tokens/{token}/info.
// Возвращает «безопасные» данные для лэндинга (без intended_email) и
// статус (для отображения «ссылка отозвана» / «истекла» / etc.).
func (h *PublicTokenHandler) PublicInfo(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
t, err := h.repo.GetByToken(r.Context(), token)
if err != nil {
writeRepoError(w, r, err, "get token")
return
}
out := publicTokenStatus{
Token: t.Token,
ResourceType: t.ResourceType,
ResourceID: t.ResourceID.String(),
Status: "valid",
}
if err := h.repo.CheckUsable(t); err != nil {
switch {
case errors.Is(err, repository.ErrTokenInvalid):
out.Status = "revoked"
case errors.Is(err, repository.ErrTokenExpired):
out.Status = "expired"
case errors.Is(err, repository.ErrTokenExhausted):
out.Status = "exhausted"
}
}
// Резолвим title ресурса (тест — title теста; курс — title курса)
// чтобы кандидат на лэндинге увидел «Вы открываете тест: Базовая…»
// и понял что собирается проходить.
switch t.ResourceType {
case "test":
test, err := h.testRepo.Get(r.Context(), t.ResourceID)
if err == nil {
out.ResourceTitle = test.Title
}
case "course":
c, err := h.courseRepo.Get(r.Context(), t.ResourceID)
if err == nil {
out.ResourceTitle = c.Title
}
}
writeJSON(w, http.StatusOK, out)
}
// Resolve — POST /public/learning/tokens/{token}/resolve {email}.
// Полный flow «кандидат жмёт ссылку из письма»:
//
// 1. Найти токен; CheckUsable (не revoked/expired/exhausted).
// 2. Сравнить email с intended_email (case-insensitive).
// 3. Создать attempt в БД, привязать к public_token_id.
// 4. Пометить opened_at; вернуть attempt_id + начальные данные.
//
// IncrementUsed НЕ дёргаем здесь — это происходит на submit'е, чтобы
// не «съесть» попытку если кандидат открыл и закрыл вкладку.
func (h *PublicTokenHandler) PublicResolve(w http.ResponseWriter, r *http.Request) {
token := chi.URLParam(r, "token")
t, err := h.repo.GetByToken(r.Context(), token)
if err != nil {
writeRepoError(w, r, err, "get token")
return
}
if err := h.repo.CheckUsable(t); err != nil {
switch {
case errors.Is(err, repository.ErrTokenInvalid):
writeError(w, http.StatusForbidden, "link is revoked")
case errors.Is(err, repository.ErrTokenExpired):
writeError(w, http.StatusGone, "link expired")
case errors.Is(err, repository.ErrTokenExhausted):
writeError(w, http.StatusTooManyRequests, "link already used")
default:
writeError(w, http.StatusForbidden, "link unavailable")
}
return
}
var req model.PublicTokenResolveRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
if err := h.repo.MatchEmail(t, req.Email); err != nil {
writeError(w, http.StatusForbidden, "email does not match the recipient")
return
}
// На этой итерации поддерживаем только resource_type='test'. Курсы
// через public-ссылку — следующая итерация (нужен view-mode для
// urls'а без логина).
if t.ResourceType != "test" {
writeError(w, http.StatusNotImplemented, "only tests are supported via public links in this version")
return
}
att, err := h.attemptRepo.Start(r.Context(), repository.StartParams{
TestID: t.ResourceID,
PublicTokenID: &t.ID,
CandidateID: t.CandidateID,
RespondentEmail: req.Email,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
})
if err != nil {
writeRepoError(w, r, err, "start attempt from public token")
return
}
if err := h.repo.MarkOpened(r.Context(), t.ID); err != nil {
// Не критично — не блокируем кандидата.
// (логирование на стороне slog'а repo'а не делаем — это hot-path).
_ = err
}
writeJSON(w, http.StatusOK, map[string]any{
"attempt": att,
"token": token,
"test_id": t.ResourceID,
})
}
// PublicGetAttempt — GET /public/learning/attempts/{id}?token=…
// Возвращает текущий attempt + вопросы (без is_correct/explanation).
// Проверка: attempt.public_token_id == token и токен ещё валиден.
func (h *PublicTokenHandler) PublicGetAttempt(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
token := r.URL.Query().Get("token")
if token == "" {
writeError(w, http.StatusBadRequest, "token query param is required")
return
}
att, err := h.attemptRepo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get attempt")
return
}
t, err := h.repo.GetByToken(r.Context(), token)
if err != nil || att.PublicTokenID == nil || *att.PublicTokenID != t.ID {
writeError(w, http.StatusForbidden, "token does not match attempt")
return
}
// Сами вопросы — через TestRepository, скрываем is_correct/explanation.
qs, err := h.testRepo.ListQuestions(r.Context(), att.TestID)
if err != nil {
writeRepoError(w, r, err, "list questions")
return
}
for i := range qs {
for j := range qs[i].Answers {
qs[i].Answers[j].IsCorrect = false
}
qs[i].Explanation = ""
}
test, _ := h.testRepo.Get(r.Context(), att.TestID)
writeJSON(w, http.StatusOK, map[string]any{
"attempt": att,
"test": test,
"questions": qs,
})
}
// PublicSubmit — POST /public/learning/attempts/{id}/submit?token=…
// Сабмит ответов + автогрейд. После успешного submit'а
// IncrementUsed увеличивает used_attempts токена.
func (h *PublicTokenHandler) PublicSubmit(w http.ResponseWriter, r *http.Request) {
id, err := parseUUID(chi.URLParam(r, "id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid id")
return
}
token := r.URL.Query().Get("token")
if token == "" {
writeError(w, http.StatusBadRequest, "token query param is required")
return
}
att, err := h.attemptRepo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get attempt")
return
}
t, err := h.repo.GetByToken(r.Context(), token)
if err != nil || att.PublicTokenID == nil || *att.PublicTokenID != t.ID {
writeError(w, http.StatusForbidden, "token does not match attempt")
return
}
if att.UserID != nil {
writeError(w, http.StatusForbidden, "attempt is not from public token")
return
}
var req model.SubmitAttemptRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
updated, err := h.attemptRepo.SubmitAndGrade(r.Context(), id, req)
if err != nil {
writeRepoError(w, r, err, "submit attempt")
return
}
// IncrementUsed best-effort. Если упадёт — попытка уже засчитана,
// токен не отметится как «использован», ребро-кейс редкий.
_ = h.repo.IncrementUsed(r.Context(), t.ID)
writeJSON(w, http.StatusOK, updated)
}