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>
419 lines
13 KiB
Go
419 lines
13 KiB
Go
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()
|
||
}
|