288 lines
9.8 KiB
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);
|
|
}
|
|
}
|