feat(public-tokens): одноразовые ссылки для кандидатов
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:
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user