feat(attempts): прохождение тестов + автогрейд single/multi
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 25s

AttemptRepository:
- Start: проверка max_attempts (учитывает уже использованные с этого
  user_id или public_token_id), вставка in_progress'а;
- Get/ListByUser/ListByTest: чтение с per-attempt scope;
- SubmitAndGrade: транзакционно сохраняет ответы в attempt_answers
  (JSONB payload + correct + score), считает итог:
    single — 1 правильный → points за вопрос, иначе 0;
    multi  — set ответов == set is_correct=TRUE → points, иначе 0
             (частичные баллы не делаем в MVP);
    text   — correct=NULL и score=NULL, ждут ручной оценки HR'ом.
  max_score = SUM(points) по всем вопросам (не только отвеченным).
  passed = NULL если у теста нет passing_score; иначе процент vs порог.
  status: graded если все автогрейд'ятся; submitted если есть text.

AttemptHandler:
- POST /tests/{id}/attempts — Start (X-User-Id из portal-gateway).
  Не-владелец стартует только если is_published=true.
- GET  /attempts/{id} — Get с проверкой «я респондент / я владелец теста».
- POST /attempts/{id}/submit — Submit (только свою попытку).
- GET  /attempts — ListMine.
- GET  /tests/{id}/attempts — ListByTest (только для владельца).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilya
2026-05-25 23:00:38 +03:00
parent 5ab6cc95cd
commit 4f9b1b1491
3 changed files with 596 additions and 5 deletions

167
internal/handler/attempt.go Normal file
View File

@@ -0,0 +1,167 @@
package handler
import (
"net/http"
"github.com/go-chi/chi/v5"
"learning-service/internal/model"
"learning-service/internal/repository"
)
type AttemptHandler struct {
repo *repository.AttemptRepository
testRepo *repository.TestRepository
}
func NewAttemptHandler(repo *repository.AttemptRepository, testRepo *repository.TestRepository) *AttemptHandler {
return &AttemptHandler{repo: repo, testRepo: testRepo}
}
// Start — POST /tests/{id}/attempts. Создаёт попытку прохождения для
// текущего юзера. Респондент берётся из X-User-Id (заголовок ставит
// portal-gateway). Для public-tokens — отдельный handler в public.go.
func (h *AttemptHandler) Start(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 test id")
return
}
// Проверим, что тест опубликован — иначе только владелец может стартовать
// (для preview). MVP: для не-владельца требуем is_published.
t, err := h.testRepo.Get(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "get test")
return
}
if !t.IsPublished && t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "test is not published")
return
}
att, err := h.repo.Start(r.Context(), repository.StartParams{
TestID: testID,
UserID: &uid,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
})
if err != nil {
writeRepoError(w, r, err, "start attempt")
return
}
writeJSON(w, http.StatusCreated, att)
}
// Get — GET /attempts/{id}. Возвращает попытку. Доступно: владелец попытки
// (user_id), владелец теста (HR-просмотр), или admin (через bypass на портале).
func (h *AttemptHandler) Get(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
}
a, err := h.repo.Get(r.Context(), id)
if err != nil {
writeRepoError(w, r, err, "get attempt")
return
}
// Доступ: я респондент или я владелец теста.
if a.UserID == nil || *a.UserID != uid {
t, err := h.testRepo.Get(r.Context(), a.TestID)
if err != nil || t.OwnerUserID != uid {
writeError(w, http.StatusForbidden, "forbidden")
return
}
}
writeJSON(w, http.StatusOK, a)
}
// Submit — POST /attempts/{id}/submit. Применяет ответы и считает score.
// После submit'а попытка переходит в submitted (если есть text-вопросы)
// или сразу в graded. Идемпотентно НЕ делаем — повторный submit вернёт
// ошибку «уже submitted».
func (h *AttemptHandler) Submit(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 attempt")
return
}
if existing.UserID == nil || *existing.UserID != uid {
writeError(w, http.StatusForbidden, "not your attempt")
return
}
var req model.SubmitAttemptRequest
if err := decodeJSON(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "invalid body")
return
}
updated, err := h.repo.SubmitAndGrade(r.Context(), id, req)
if err != nil {
writeRepoError(w, r, err, "submit attempt")
return
}
writeJSON(w, http.StatusOK, updated)
}
// ListMine — GET /attempts?mine=true. История моих попыток.
func (h *AttemptHandler) ListMine(w http.ResponseWriter, r *http.Request) {
uid, ok := userIDFromHeader(r)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
items, err := h.repo.ListByUser(r.Context(), uid)
if err != nil {
writeRepoError(w, r, err, "list my attempts")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}
// ListByTest — GET /tests/{id}/attempts. Список попыток для HR (владелец теста).
func (h *AttemptHandler) ListByTest(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 test id")
return
}
t, err := h.testRepo.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 view attempts")
return
}
items, err := h.repo.ListByTest(r.Context(), testID)
if err != nil {
writeRepoError(w, r, err, "list test attempts")
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items})
}

View File

@@ -0,0 +1,418 @@
package repository
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"learning-service/internal/model"
)
// AttemptRepository — попытки прохождения теста + автогрейд.
//
// Сценарии:
// 1. Сотрудник из портала: Start с userID, без public_token_id.
// 2. Кандидат по public-ссылке: Start с publicTokenID и respondentName/email.
//
// SubmitAndGrade — один transactional проход: пишет ответы, считает score
// по правильным test_answers, маркирует passed по test.passing_score.
// Для text-вопросов correct=NULL и score=NULL — ждут ручной оценки.
type AttemptRepository struct {
pool *pgxpool.Pool
}
func NewAttemptRepository(pool *pgxpool.Pool) *AttemptRepository {
return &AttemptRepository{pool: pool}
}
const attemptCols = `
id, test_id, user_id, public_token_id, candidate_id, status,
score, max_score, passed,
respondent_name, respondent_email,
started_at, submitted_at, graded_at, ip, user_agent
`
func scanAttempt(scan func(...any) error) (*model.Attempt, error) {
var a model.Attempt
if err := scan(
&a.ID, &a.TestID, &a.UserID, &a.PublicTokenID, &a.CandidateID, &a.Status,
&a.Score, &a.MaxScore, &a.Passed,
&a.RespondentName, &a.RespondentEmail,
&a.StartedAt, &a.SubmittedAt, &a.GradedAt, &a.IP, &a.UserAgent,
); err != nil {
return nil, err
}
return &a, nil
}
// StartParams — параметры старта попытки. Один из userID / publicTokenID
// обязателен (CHECK на уровне БД).
type StartParams struct {
TestID uuid.UUID
UserID *uuid.UUID
PublicTokenID *uuid.UUID
CandidateID *int64
RespondentName string
RespondentEmail string
IP string
UserAgent string
}
// Start — создаёт новую попытку. Проверяет max_attempts: считает существующие
// попытки этого test_id × user_id (или × public_token_id для guest'ов) и
// возвращает ошибку, если лимит достигнут.
func (r *AttemptRepository) Start(ctx context.Context, p StartParams) (*model.Attempt, error) {
// max_attempts проверяем заранее — иначе UI узнает только после INSERT'а.
var maxAttempts int
if err := r.pool.QueryRow(ctx,
`SELECT max_attempts FROM tests WHERE id = $1`, p.TestID).Scan(&maxAttempts); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if maxAttempts > 0 {
var used int
var err error
switch {
case p.UserID != nil:
err = r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM test_attempts
WHERE test_id = $1 AND user_id = $2 AND status <> 'expired'`,
p.TestID, *p.UserID).Scan(&used)
case p.PublicTokenID != nil:
err = r.pool.QueryRow(ctx, `
SELECT COUNT(*) FROM test_attempts
WHERE test_id = $1 AND public_token_id = $2 AND status <> 'expired'`,
p.TestID, *p.PublicTokenID).Scan(&used)
}
if err != nil {
return nil, err
}
if used >= maxAttempts {
return nil, fmt.Errorf("max attempts reached (%d)", maxAttempts)
}
}
var id uuid.UUID
err := r.pool.QueryRow(ctx, `
INSERT INTO test_attempts (
test_id, user_id, public_token_id, candidate_id, status,
respondent_name, respondent_email, ip, user_agent
) VALUES ($1, $2, $3, $4, 'in_progress', $5, $6, $7, $8)
RETURNING id`,
p.TestID, p.UserID, p.PublicTokenID, p.CandidateID,
p.RespondentName, p.RespondentEmail, p.IP, p.UserAgent,
).Scan(&id)
if err != nil {
return nil, err
}
return r.Get(ctx, id)
}
func (r *AttemptRepository) Get(ctx context.Context, id uuid.UUID) (*model.Attempt, error) {
a, err := scanAttempt(r.pool.QueryRow(ctx,
`SELECT `+attemptCols+` FROM test_attempts WHERE id = $1`, id).Scan)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
return a, nil
}
// ListByTest — попытки для теста (для HR-просмотра «кто проходил»).
// Сортировка свежие сверху; пагинации в MVP нет (lim 200).
func (r *AttemptRepository) ListByTest(ctx context.Context, testID uuid.UUID) ([]model.Attempt, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+attemptCols+` FROM test_attempts WHERE test_id = $1
ORDER BY started_at DESC LIMIT 200`, testID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Attempt{}
for rows.Next() {
a, err := scanAttempt(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
// ListByUser — мои попытки (для страницы «История прохождений»).
func (r *AttemptRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]model.Attempt, error) {
rows, err := r.pool.Query(ctx,
`SELECT `+attemptCols+` FROM test_attempts WHERE user_id = $1
ORDER BY started_at DESC LIMIT 200`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Attempt{}
for rows.Next() {
a, err := scanAttempt(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
// SubmitAndGrade — атомарно: сохраняет ответы пользователя, считает score
// по test_answers (для single/multi), маркирует passed/status.
//
// Логика автогрейда:
// single: 1 правильный ответ — points за вопрос; иначе 0.
// multi: выбранные answer_ids == множеству is_correct=TRUE — points;
// иначе 0 (частичные баллы не делаем в MVP).
// text: correct=NULL, score=NULL — ждут ручной оценки HR'ом.
// max_score такого вопроса всё равно учитывается в общем max.
//
// passed: NULL если у теста нет passing_score; иначе сравниваем
// score/max_score × 100 с порогом.
func (r *AttemptRepository) SubmitAndGrade(ctx context.Context, attemptID uuid.UUID, req model.SubmitAttemptRequest) (*model.Attempt, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer func() { _ = tx.Rollback(ctx) }()
// Загрузить попытку + test (для passing_score).
var a model.Attempt
err = tx.QueryRow(ctx,
`SELECT `+attemptCols+` FROM test_attempts WHERE id = $1 FOR UPDATE`, attemptID).Scan(
&a.ID, &a.TestID, &a.UserID, &a.PublicTokenID, &a.CandidateID, &a.Status,
&a.Score, &a.MaxScore, &a.Passed,
&a.RespondentName, &a.RespondentEmail,
&a.StartedAt, &a.SubmittedAt, &a.GradedAt, &a.IP, &a.UserAgent,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
if a.Status != "in_progress" {
return nil, fmt.Errorf("attempt is %s, cannot submit", a.Status)
}
var passingScore *int
if err := tx.QueryRow(ctx, `SELECT passing_score FROM tests WHERE id = $1`, a.TestID).Scan(&passingScore); err != nil {
return nil, err
}
// Загрузить вопросы теста + ответы — для каждого вопроса нужно знать
// правильные answer_ids и points.
type qDef struct {
Kind string
Points int
// CorrectIDs — отсортированный список правильных ответов
// (для multi сравниваем как множества).
CorrectIDs []string
}
defs := map[uuid.UUID]*qDef{}
qRows, err := tx.Query(ctx, `SELECT id, kind, points FROM test_questions WHERE test_id = $1`, a.TestID)
if err != nil {
return nil, err
}
for qRows.Next() {
var qid uuid.UUID
d := &qDef{}
if err := qRows.Scan(&qid, &d.Kind, &d.Points); err != nil {
qRows.Close()
return nil, err
}
defs[qid] = d
}
qRows.Close()
if len(defs) == 0 {
return nil, fmt.Errorf("test has no questions")
}
// Все правильные answer_ids одним батчем.
qIDs := make([]uuid.UUID, 0, len(defs))
for id := range defs {
qIDs = append(qIDs, id)
}
aRows, err := tx.Query(ctx,
`SELECT question_id, id FROM test_answers WHERE question_id = ANY($1) AND is_correct = TRUE`, qIDs)
if err != nil {
return nil, err
}
for aRows.Next() {
var qid, aid uuid.UUID
if err := aRows.Scan(&qid, &aid); err != nil {
aRows.Close()
return nil, err
}
if d := defs[qid]; d != nil {
d.CorrectIDs = append(d.CorrectIDs, aid.String())
}
}
aRows.Close()
for _, d := range defs {
sort.Strings(d.CorrectIDs)
}
// Полный max_score — сумма points по ВСЕМ вопросам теста, не только
// отвеченным. Иначе пропуск вопроса завышал бы процент.
maxScore := 0
for _, d := range defs {
maxScore += d.Points
}
// Пройтись по каждому ответу пользователя, посчитать correct + score,
// записать в test_attempt_answers (UPSERT).
totalScore := 0
for _, ans := range req.Answers {
d, ok := defs[ans.QuestionID]
if !ok {
// Вопрос не из этого теста — игнорируем. Альтернатива — ошибку
// возвращать; в MVP толерантно, чтобы фронт мог слать «лишнее».
continue
}
var correctPtr *bool
var scorePtr *int
payload, _ := json.Marshal(map[string]any{
"answer_id": ans.AnswerID,
"answer_ids": ans.AnswerIDs,
"text": ans.Text,
})
switch d.Kind {
case "single":
isCorrect := ans.AnswerID != nil && len(d.CorrectIDs) == 1 && ans.AnswerID.String() == d.CorrectIDs[0]
correctPtr = &isCorrect
s := 0
if isCorrect {
s = d.Points
}
scorePtr = &s
totalScore += s
case "multi":
picked := make([]string, 0, len(ans.AnswerIDs))
for _, id := range ans.AnswerIDs {
picked = append(picked, id.String())
}
sort.Strings(picked)
eq := len(picked) == len(d.CorrectIDs)
if eq {
for i := range picked {
if picked[i] != d.CorrectIDs[i] {
eq = false
break
}
}
}
correctPtr = &eq
s := 0
if eq {
s = d.Points
}
scorePtr = &s
totalScore += s
case "text":
// correctPtr=nil, scorePtr=nil — ждёт ручной оценки.
}
if _, err := tx.Exec(ctx, `
INSERT INTO test_attempt_answers (attempt_id, question_id, payload, correct, score)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (attempt_id, question_id) DO UPDATE
SET payload = EXCLUDED.payload,
correct = EXCLUDED.correct,
score = EXCLUDED.score,
answered_at = NOW()`,
attemptID, ans.QuestionID, payload, correctPtr, scorePtr); err != nil {
return nil, err
}
}
// Определяем passed по passing_score. Если у теста есть text-вопросы
// без ручной оценки, passed считаем по auto-graded части — это
// «предварительная оценка», HR может пересчитать позже.
var passed *bool
if passingScore != nil && maxScore > 0 {
pct := int(float64(totalScore) * 100 / float64(maxScore))
v := pct >= *passingScore
passed = &v
}
now := time.Now().UTC()
// status: submitted (есть text без оценки) или graded (всё auto).
hasText := false
for _, d := range defs {
if d.Kind == "text" {
hasText = true
break
}
}
finalStatus := "graded"
gradedAt := &now
if hasText {
finalStatus = "submitted"
gradedAt = nil
}
if _, err := tx.Exec(ctx, `
UPDATE test_attempts
SET status = $2,
score = $3,
max_score = $4,
passed = $5,
submitted_at = $6,
graded_at = $7
WHERE id = $1`,
attemptID, finalStatus, totalScore, maxScore, passed, now, gradedAt); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return r.Get(ctx, attemptID)
}
// ListAnswers — ответы попытки. Для UI «мои результаты»: подсветка
// правильно/неправильно для auto-graded вопросов, raw payload для text.
func (r *AttemptRepository) ListAnswers(ctx context.Context, attemptID uuid.UUID) (map[uuid.UUID]struct {
Payload []byte
Correct *bool
Score *int
}, error) {
rows, err := r.pool.Query(ctx, `
SELECT question_id, payload, correct, score
FROM test_attempt_answers WHERE attempt_id = $1`, attemptID)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uuid.UUID]struct {
Payload []byte
Correct *bool
Score *int
}{}
for rows.Next() {
var qid uuid.UUID
var raw []byte
var correct *bool
var score *int
if err := rows.Scan(&qid, &raw, &correct, &score); err != nil {
return nil, err
}
out[qid] = struct {
Payload []byte
Correct *bool
Score *int
}{raw, correct, score}
}
return out, rows.Err()
}