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

@@ -70,12 +70,14 @@ func main() {
attemptRepo := repository.NewAttemptRepository(pool)
courseRepo := repository.NewCourseRepository(pool)
lessonRepo := repository.NewLessonRepository(pool)
publicTokenRepo := repository.NewPublicTokenRepository(pool)
healthH := handler.NewHealthHandler(pool)
testH := handler.NewTestHandler(testRepo)
attemptH := handler.NewAttemptHandler(attemptRepo, testRepo)
courseH := handler.NewCourseHandler(courseRepo)
lessonH := handler.NewLessonHandler(lessonRepo, courseRepo, store)
publicTokenH := handler.NewPublicTokenHandler(publicTokenRepo, testRepo, courseRepo, attemptRepo)
r := chi.NewRouter()
r.Use(chimw.RequestID)
@@ -136,17 +138,25 @@ func main() {
r.Get("/lessons/{id}/video/stream", lessonH.StreamVideo)
r.Delete("/lessons/{id}/video", lessonH.DeleteVideo)
r.HandleFunc("/access/{resourceType}/{resourceId}", notImplemented)
r.HandleFunc("/public-tokens", notImplemented)
r.HandleFunc("/public-tokens/{id}", notImplemented)
// Public tokens — HR-side: создать ссылку для кандидата, посмотреть
// список, отозвать. Сам прохождение тестa по токену — в /public ниже.
r.Post("/public-tokens", publicTokenH.Create)
r.Get("/public-tokens", publicTokenH.ListByResource)
r.Delete("/public-tokens/{id}", publicTokenH.Revoke)
})
// Public endpoints — без InternalAuth (кандидаты ходят анонимно
// по token'у). Открываем минимум: проверка токена + старт attempt +
// сабмит. Содержательно гейтим через token-валидацию в самом handler'е.
// по token'у). Гейтят через сам 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)
// Info — лёгкий read для лэндинга (проверка валидности + title).
r.Get("/learning/tokens/{token}/info", publicTokenH.PublicInfo)
// Resolve — кандидат вводит email, бэк сверяет с intended_email
// и создаёт attempt. Возвращает attempt_id + первое чтение теста.
r.Post("/learning/tokens/{token}/resolve", publicTokenH.PublicResolve)
// Attempts — read/submit с обязательным ?token=… в query.
r.Get("/learning/attempts/{id}", publicTokenH.PublicGetAttempt)
r.Post("/learning/attempts/{id}/submit", publicTokenH.PublicSubmit)
})
srv := &http.Server{