findResumeAttempt($user, $bank, $mode, $filters); if ($resumeAttempt !== null) { return $resumeAttempt->load('items.question.options'); } $query = Question::query() ->where('question_bank_id', $bank->id) ->where('is_active', true) ->with(['options', 'tags']); if (str_contains($mode, 'wrong')) { $query->whereHas('wrongQuestions', fn ($wrongQuery) => $wrongQuery ->where('user_id', $user->id) ->whereNull('mastered_at')); } if (! empty($filters['category_id'])) { $query->where('category_id', $filters['category_id']); } if (! empty($filters['type'])) { $types = is_array($filters['type']) ? $filters['type'] : [$filters['type']]; $query->whereIn('type', array_values(array_filter($types))); } if (! empty($filters['tag_ids']) && is_array($filters['tag_ids'])) { $tagIds = array_values(array_filter(array_map('intval', $filters['tag_ids']))); if ($tagIds !== []) { $query->whereHas('tags', fn ($tagQuery) => $tagQuery->whereIn('question_tags.id', $tagIds)); } } if (in_array($mode, ['random', 'wrong_random'], true)) { $limit = min(max((int) ($filters['limit'] ?? 20), 1), 100); $questions = $query ->orderBy('type') ->orderBy('id') ->get() ->groupBy('type') ->pipe(fn ($groups) => $groups->flatMap(fn ($items) => $items->shuffle())) ->take($limit) ->values(); } else { $query->orderBy('id'); if (array_key_exists('limit', $filters)) { $query->limit(min(max((int) $filters['limit'], 1), 100)); } $questions = $query->get(); } if ($questions->isEmpty()) { throw ValidationException::withMessages(['question_bank_id' => '没有可用题目']); } return $this->createAttempt($user, $mode, $questions->all(), [ 'question_bank_id' => $bank->id, 'draw_rule' => $filters, ]); } private function findResumeAttempt(User $user, QuestionBank $bank, string $mode, array $filters): ?QuizAttempt { if (in_array($mode, ['random', 'wrong_random'], true)) { return null; } return QuizAttempt::query() ->where('user_id', $user->id) ->where('question_bank_id', $bank->id) ->where('mode', $mode) ->where('status', 'in_progress') ->latest() ->get() ->first(function (QuizAttempt $attempt) use ($filters): bool { return $this->sameDrawRule($attempt->draw_rule ?? [], $filters); }); } /** * @param array $left * @param array $right */ private function sameDrawRule(array $left, array $right): bool { $normalize = function (array $rule): array { $rule['tag_ids'] = array_values(array_filter(array_map('intval', (array) ($rule['tag_ids'] ?? [])))); sort($rule['tag_ids']); $rule['type'] = array_values(array_filter((array) ($rule['type'] ?? []))); sort($rule['type']); unset($rule['limit']); return $rule; }; return $normalize($left) == $normalize($right); } public function startPaper(User $user, Paper $paper): QuizAttempt { $questions = $paper->questions()->with('options')->get(); if ($questions->isEmpty()) { throw ValidationException::withMessages(['paper_id' => '试卷没有题目']); } if ($paper->attempt_limit !== null) { $usedAttempts = QuizAttempt::query() ->where('user_id', $user->id) ->where('paper_id', $paper->id) ->whereIn('status', ['in_progress', 'submitted']) ->count(); if ($usedAttempts >= $paper->attempt_limit) { throw ValidationException::withMessages(['paper_id' => '已达到试卷作答次数限制']); } } return $this->createAttempt($user, 'paper', $questions->all(), [ 'paper_id' => $paper->id, 'question_bank_id' => $paper->question_bank_id, 'expires_at' => $paper->duration_minutes ? now()->addMinutes($paper->duration_minutes) : null, ]); } public function answer(User $user, QuizAttempt $attempt, int $questionId, array $answer, int $durationSeconds = 0): QuizAttemptQuestion { if ($attempt->user_id !== $user->id) { throw ValidationException::withMessages(['attempt' => '无权访问该记录']); } if ($attempt->status !== 'in_progress') { throw ValidationException::withMessages(['attempt' => '该记录已结束']); } if ($attempt->expires_at && $attempt->expires_at->isPast()) { $this->submit($user, $attempt); throw ValidationException::withMessages(['attempt' => '测试已超时并自动交卷']); } $item = $attempt->items()->where('question_id', $questionId)->with('question.options')->firstOrFail(); $isCorrect = $this->judge($item->question, $answer); $item->update([ 'answer' => array_values($answer), 'is_correct' => $isCorrect, 'duration_seconds' => $durationSeconds, 'answered_at' => now(), ]); $this->syncWrongQuestion($user, $item->question, $isCorrect); return $item->fresh('question.options'); } public function submit(User $user, QuizAttempt $attempt): QuizAttempt { if ($attempt->user_id !== $user->id) { throw ValidationException::withMessages(['attempt' => '无权访问该记录']); } $items = $attempt->items()->get(); $correct = $items->where('is_correct', true)->count(); $score = $items->where('is_correct', true)->sum('score'); $attempt->update([ 'status' => 'submitted', 'submitted_at' => now(), 'correct_count' => $correct, 'score' => $score, ]); return $attempt->fresh('items.question.options'); } /** * @param array $questions * @param array $attributes */ private function createAttempt(User $user, string $mode, array $questions, array $attributes): QuizAttempt { return DB::transaction(function () use ($user, $mode, $questions, $attributes): QuizAttempt { $attempt = QuizAttempt::create([ 'user_id' => $user->id, 'paper_id' => $attributes['paper_id'] ?? null, 'question_bank_id' => $attributes['question_bank_id'] ?? null, 'mode' => $mode, 'status' => 'in_progress', 'draw_rule' => $attributes['draw_rule'] ?? null, 'started_at' => now(), 'expires_at' => $attributes['expires_at'] ?? null, 'total_questions' => count($questions), ]); foreach ($questions as $sort => $question) { $attempt->items()->create([ 'question_id' => $question->id, 'score' => $this->questionScore($question), 'sort' => $sort, ]); } return $attempt->fresh('items.question.options'); }); } private function judge(Question $question, array $answer): bool { if ($question->type === 'blank') { $expected = array_map('trim', $question->answers ?? []); $actual = array_map('trim', array_map('strval', $answer)); return $expected === $actual; } $correct = $question->correctOptionIds(); $actual = array_map('intval', $answer); sort($correct); sort($actual); return $correct === $actual; } private function syncWrongQuestion(User $user, Question $question, bool $isCorrect): void { $wrong = WrongQuestion::firstOrNew([ 'user_id' => $user->id, 'question_id' => $question->id, ]); if ($isCorrect) { if ($wrong->exists) { $wrong->consecutive_correct_count++; if ($wrong->consecutive_correct_count >= 3) { $wrong->mastered_at = now(); } $wrong->save(); } return; } $wrong->wrong_count = $wrong->exists ? $wrong->wrong_count + 1 : 1; $wrong->consecutive_correct_count = 0; $wrong->mastered_at = null; $wrong->last_wrong_at = now(); $wrong->save(); } private function defaultScore(string $type): float { return match ($type) { 'multiple' => 2.0, 'blank' => 2.0, default => 1.0, }; } private function questionScore(Question $question): float { $pivotScore = $question->getAttribute('pivot')?->score; if ($pivotScore !== null) { return (float) $pivotScore; } return $this->defaultScore($question->type); } }