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>
This commit is contained in:
418
internal/repository/attempt.go
Normal file
418
internal/repository/attempt.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user