Files
learning/internal/repository/attempt.go
Ilya 4f9b1b1491
Some checks failed
CI / test (push) Failing after 10s
Build and Deploy / build-and-deploy (push) Successful in 25s
feat(attempts): прохождение тестов + автогрейд single/multi
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>
2026-05-25 23:00:38 +03:00

419 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}