QuickQuiz/app/Services/QuizService.php

288 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Paper;
use App\Models\Question;
use App\Models\QuestionBank;
use App\Models\QuizAttempt;
use App\Models\QuizAttemptQuestion;
use App\Models\User;
use App\Models\WrongQuestion;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
final class QuizService
{
public function startPractice(User $user, QuestionBank $bank, string $mode, array $filters = []): QuizAttempt
{
$resumeAttempt = $this->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_memorize', 'wrong_practice', '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<string, mixed> $left
* @param array<string, mixed> $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<int, Question> $questions
* @param array<string, mixed> $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);
}
}