$input * @return array */ public function filters(array $input): array { $from = filled($input['date_from'] ?? null) ? CarbonImmutable::parse((string) $input['date_from'])->startOfDay() : now()->subDays(6)->startOfDay()->toImmutable(); $to = filled($input['date_to'] ?? null) ? CarbonImmutable::parse((string) $input['date_to'])->endOfDay() : now()->endOfDay()->toImmutable(); return [ 'date_from' => $from, 'date_to' => $to, 'class_id' => $this->nullableInt($input['class_id'] ?? null), 'user_id' => $this->nullableInt($input['user_id'] ?? null), 'question_bank_id' => $this->nullableInt($input['question_bank_id'] ?? null), 'category_id' => $this->nullableInt($input['category_id'] ?? null), 'tag_id' => $this->nullableInt($input['tag_id'] ?? null), 'type' => filled($input['type'] ?? null) ? (string) $input['type'] : null, 'mode' => filled($input['mode'] ?? null) ? (string) $input['mode'] : null, ]; } /** * @param array $filters * @return array */ public function overview(User $user, array $filters): array { $answered = $this->answeredItems($user, $filters) ->selectRaw('count(*) as answered_count') ->selectRaw('count(distinct qa.id) as attempts') ->selectRaw('count(distinct qa.user_id) as active_students') ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') ->first(); $totalItems = $this->allItems($user, $filters)->count(); $studentIds = $this->scopedStudentIds($user, $filters); $wrongBase = $this->wrongQuestions($user, $filters); $answeredCount = (int) ($answered->answered_count ?? 0); $correctCount = (int) ($answered->correct_count ?? 0); return [ 'students' => $studentIds->count(), 'active_students' => (int) ($answered->active_students ?? 0), 'attempts' => (int) ($answered->attempts ?? 0), 'answered_count' => $answeredCount, 'accuracy' => $this->percent($correctCount, $answeredCount), 'completion_rate' => $this->percent($answeredCount, $totalItems), 'avg_duration_seconds' => round((float) ($answered->avg_duration_seconds ?? 0), 1), 'wrong_questions' => (clone $wrongBase)->whereNull('wrong_questions.mastered_at')->count(), 'mastered_wrong_questions' => (clone $wrongBase)->whereNotNull('wrong_questions.mastered_at')->count(), ]; } /** * @param array $filters * @return array> */ public function trends(User $user, array $filters): array { $rows = $this->answeredItems($user, $filters) ->selectRaw('date(qaq.answered_at) as day') ->selectRaw('count(distinct qa.id) as attempts') ->selectRaw('count(distinct qa.user_id) as active_students') ->selectRaw('count(*) as answered_count') ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') ->groupBy('day') ->orderBy('day') ->get() ->keyBy('day'); $wrongRows = $this->wrongQuestions($user, $filters) ->whereBetween('wrong_questions.last_wrong_at', [$filters['date_from'], $filters['date_to']]) ->selectRaw('date(wrong_questions.last_wrong_at) as day, count(*) as wrong_added') ->groupBy('day') ->pluck('wrong_added', 'day'); $days = []; $cursor = CarbonImmutable::parse($filters['date_from'])->startOfDay(); $end = CarbonImmutable::parse($filters['date_to'])->startOfDay(); while ($cursor <= $end) { $day = $cursor->toDateString(); $row = $rows->get($day); $answeredCount = (int) ($row->answered_count ?? 0); $correctCount = (int) ($row->correct_count ?? 0); $days[] = [ 'day' => $day, 'attempts' => (int) ($row->attempts ?? 0), 'active_students' => (int) ($row->active_students ?? 0), 'answered_count' => $answeredCount, 'accuracy' => $this->percent($correctCount, $answeredCount), 'avg_duration_seconds' => round((float) ($row->avg_duration_seconds ?? 0), 1), 'wrong_added' => (int) ($wrongRows[$day] ?? 0), ]; $cursor = $cursor->addDay(); } return $days; } /** * @param array $filters */ public function questionErrors(User $user, array $filters, int $perPage = 20): LengthAwarePaginator { return $this->questionErrorQuery($user, $filters) ->paginate($perPage) ->through(fn (object $row): array => $this->questionErrorRow($row)); } /** * @param array $filters * @return array> */ public function classRanking(User $user, array $filters): array { return $this->visibleClasses($user, $filters) ->limit(20) ->get() ->map(function (object $class) use ($user, $filters): array { $classFilters = $filters + ['class_id' => (int) $class->id]; $classFilters['class_id'] = (int) $class->id; $overview = $this->overview($user, $classFilters); return [ 'id' => (int) $class->id, 'name' => $class->name, 'members_count' => (int) $class->members_count, 'attempts' => $overview['attempts'], 'answered_count' => $overview['answered_count'], 'completion_rate' => $overview['completion_rate'], 'avg_duration_seconds' => $overview['avg_duration_seconds'], 'accuracy' => $overview['accuracy'], ]; }) ->sortByDesc('answered_count') ->values() ->all(); } /** * @param array $filters * @return array */ public function mastery(User $user, array $filters): array { return [ 'banks' => $this->dimensionRows($user, $filters, 'qb.id', 'qb.name', ['qb.id', 'qb.name'], 20), 'categories' => $this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 30, [ 'bank_name' => 'qb.name', ]), 'types' => $this->dimensionRows($user, $filters, 'q.type', 'q.type', ['q.type'], 10), 'tags' => $this->tagRows($user, $filters, 30), ]; } /** * @param array $filters */ public function students(User $user, array $filters, int $perPage = 20): LengthAwarePaginator { return $this->answeredItems($user, $filters) ->join('users as u', 'u.id', '=', 'qa.user_id') ->leftJoin('wrong_questions as wq', function ($join): void { $join->on('wq.user_id', '=', 'u.id') ->on('wq.question_id', '=', 'q.id') ->whereNull('wq.mastered_at'); }) ->selectRaw('u.id, u.name, u.email') ->selectRaw('count(distinct qa.id) as attempts') ->selectRaw('count(*) as answered_count') ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') ->selectRaw('count(distinct wq.id) as wrong_questions') ->selectRaw('max(qaq.answered_at) as last_answered_at') ->groupBy('u.id', 'u.name', 'u.email') ->orderByDesc('answered_count') ->paginate($perPage) ->through(fn (object $row): array => $this->studentRow($row)); } /** * @param array $filters * @return array */ public function studentDetail(User $viewer, User $student, array $filters): array { $filters['user_id'] = $student->id; abort_if(! $this->canViewStudent($viewer, $student->id), 403); return [ 'student' => ['id' => $student->id, 'name' => $student->name, 'email' => $student->email], 'overview' => $this->overview($viewer, $filters), 'banks' => $this->dimensionRows($viewer, $filters, 'qb.id', 'qb.name', ['qb.id', 'qb.name'], 10), 'categories' => $this->dimensionRows($viewer, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 10, ['bank_name' => 'qb.name']), 'types' => $this->dimensionRows($viewer, $filters, 'q.type', 'q.type', ['q.type'], 10), 'question_errors' => $this->questionErrorQuery($viewer, $filters)->limit(10)->get()->map(fn (object $row): array => $this->questionErrorRow($row))->all(), ]; } /** * @param array $filters * @return array */ public function bankDetail(User $user, int $bankId, array $filters): array { $filters['question_bank_id'] = $bankId; abort_if(! $this->canViewBank($user, $bankId), 403); return [ 'overview' => $this->overview($user, $filters), 'categories' => $this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name'], 20), 'tags' => $this->tagRows($user, $filters, 20), 'types' => $this->dimensionRows($user, $filters, 'q.type', 'q.type', ['q.type'], 10), 'question_errors' => $this->questionErrorQuery($user, $filters)->limit(20)->get()->map(fn (object $row): array => $this->questionErrorRow($row))->all(), ]; } /** * @param array $filters * @return array */ public function insights(User $user, array $filters): array { $students = $this->students($user, $filters, 100)->getCollection() ->filter(fn (array $row): bool => $row['accuracy'] < 60 && $row['answered_count'] >= 10) ->take(5) ->values() ->all(); $questions = $this->questionErrorQuery($user, $filters) ->havingRaw('wrong_count >= 3') ->limit(5) ->get() ->map(fn (object $row): array => $this->questionErrorRow($row)) ->filter(fn (array $row): bool => $row['wrong_rate'] > 50 && $row['attempts'] >= 5) ->values() ->all(); $categories = collect($this->dimensionRows($user, $filters, 'q.category_id', 'coalesce(qc.name, "未分类")', ['q.category_id', 'qc.name', 'qb.name'], 30, ['bank_name' => 'qb.name'])) ->filter(fn (array $row): bool => $row['accuracy'] < 65 && $row['answered_count'] >= 10) ->take(5) ->values() ->all(); return [ 'students' => $students, 'questions' => $questions, 'categories' => $categories, ]; } /** * @param array $filters * @return array */ public function options(User $user, array $filters): array { $bankQuery = DB::table('question_banks') ->select('id', 'name') ->when($user->role !== 'admin', fn (Builder $query) => $query->where('owner_id', $user->id)) ->orderBy('name'); $classQuery = DB::table('classes') ->select('id', 'name') ->when($user->role !== 'admin', fn (Builder $query) => $query->where('owner_id', $user->id)) ->orderBy('name'); $studentIds = $this->scopedStudentIds($user, $filters); return [ 'banks' => $bankQuery->get(), 'classes' => $classQuery->get(), 'students' => User::query()->select('id', 'name', 'email')->whereIn('id', $studentIds)->orderBy('name')->limit(200)->get(), 'categories' => DB::table('question_categories') ->join('question_banks', 'question_banks.id', '=', 'question_categories.question_bank_id') ->select('question_categories.id', 'question_categories.name', 'question_categories.question_bank_id') ->when($user->role !== 'admin', fn (Builder $query) => $query->where('question_banks.owner_id', $user->id)) ->when($filters['question_bank_id'] ?? null, fn (Builder $query, int $bankId) => $query->where('question_categories.question_bank_id', $bankId)) ->orderBy('question_categories.name') ->get(), 'tags' => DB::table('question_tags') ->join('question_banks', 'question_banks.id', '=', 'question_tags.question_bank_id') ->select('question_tags.id', 'question_tags.name', 'question_tags.question_bank_id') ->when($user->role !== 'admin', fn (Builder $query) => $query->where('question_banks.owner_id', $user->id)) ->when($filters['question_bank_id'] ?? null, fn (Builder $query, int $bankId) => $query->where('question_tags.question_bank_id', $bankId)) ->orderBy('question_tags.name') ->get(), ]; } /** * @param array $filters * @return array */ public function exportPayload(User $user, array $filters): array { return [ 'filters' => [ 'date_from' => CarbonImmutable::parse($filters['date_from'])->toDateString(), 'date_to' => CarbonImmutable::parse($filters['date_to'])->toDateString(), ] + collect($filters)->except(['date_from', 'date_to'])->filter()->all(), 'overview' => $this->overview($user, $filters), 'trends' => $this->trends($user, $filters), 'class_ranking' => $this->classRanking($user, $filters), 'students' => $this->students($user, $filters, 1000)->items(), 'mastery' => $this->mastery($user, $filters), 'question_errors' => $this->questionErrors($user, $filters, 1000)->items(), 'insights' => $this->insights($user, $filters), ]; } /** * @param array $filters */ private function answeredItems(User $user, array $filters): Builder { return $this->itemBase($user, $filters) ->whereNotNull('qaq.answered_at') ->whereBetween('qaq.answered_at', [$filters['date_from'], $filters['date_to']]); } /** * @param array $filters */ private function allItems(User $user, array $filters): Builder { return $this->itemBase($user, $filters) ->whereBetween('qa.started_at', [$filters['date_from'], $filters['date_to']]); } /** * @param array $filters */ private function itemBase(User $user, array $filters): Builder { return DB::table('quiz_attempt_questions as qaq') ->join('quiz_attempts as qa', 'qa.id', '=', 'qaq.quiz_attempt_id') ->join('questions as q', 'q.id', '=', 'qaq.question_id') ->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id') ->leftJoin('question_categories as qc', 'qc.id', '=', 'q.category_id') ->whereNull('q.deleted_at') ->whereNull('qb.deleted_at') ->tap(fn (Builder $query) => $this->applyScope($query, $user)) ->tap(fn (Builder $query) => $this->applyFilters($query, $filters)); } private function applyScope(Builder $query, User $user): void { if ($user->role === 'admin') { return; } $query->where(function (Builder $scope) use ($user): void { $scope->where('qb.owner_id', $user->id) ->orWhereExists(function (Builder $exists) use ($user): void { $exists->selectRaw('1') ->from('class_members as scope_cm') ->join('classes as scope_c', 'scope_c.id', '=', 'scope_cm.class_id') ->whereColumn('scope_cm.user_id', 'qa.user_id') ->where('scope_c.owner_id', $user->id) ->whereNull('scope_c.deleted_at'); }); }); } /** * @param array $filters */ private function applyFilters(Builder $query, array $filters): void { $query ->when($filters['class_id'] ?? null, fn (Builder $builder, int $classId) => $builder->whereExists(function (Builder $exists) use ($classId): void { $exists->selectRaw('1') ->from('class_members as filter_cm') ->whereColumn('filter_cm.user_id', 'qa.user_id') ->where('filter_cm.class_id', $classId); })) ->when($filters['user_id'] ?? null, fn (Builder $builder, int $userId) => $builder->where('qa.user_id', $userId)) ->when($filters['question_bank_id'] ?? null, fn (Builder $builder, int $bankId) => $builder->where('q.question_bank_id', $bankId)) ->when($filters['category_id'] ?? null, fn (Builder $builder, int $categoryId) => $builder->where('q.category_id', $categoryId)) ->when($filters['tag_id'] ?? null, fn (Builder $builder, int $tagId) => $builder->whereExists(function (Builder $exists) use ($tagId): void { $exists->selectRaw('1') ->from('question_tag as filter_qt') ->whereColumn('filter_qt.question_id', 'q.id') ->where('filter_qt.question_tag_id', $tagId); })) ->when($filters['type'] ?? null, fn (Builder $builder, string $type) => $builder->where('q.type', $type)) ->when($filters['mode'] ?? null, fn (Builder $builder, string $mode) => $builder->where('qa.mode', $mode)); } /** * @param array $filters */ private function wrongQuestions(User $user, array $filters): Builder { return DB::table('wrong_questions') ->join('questions as q', 'q.id', '=', 'wrong_questions.question_id') ->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id') ->leftJoin('question_categories as qc', 'qc.id', '=', 'q.category_id') ->whereNull('q.deleted_at') ->whereNull('qb.deleted_at') ->tap(fn (Builder $query) => $this->applyWrongScope($query, $user)) ->tap(fn (Builder $query) => $this->applyWrongFilters($query, $filters)); } private function applyWrongScope(Builder $query, User $user): void { if ($user->role === 'admin') { return; } $query->where(function (Builder $scope) use ($user): void { $scope->where('qb.owner_id', $user->id) ->orWhereExists(function (Builder $exists) use ($user): void { $exists->selectRaw('1') ->from('class_members as scope_cm') ->join('classes as scope_c', 'scope_c.id', '=', 'scope_cm.class_id') ->whereColumn('scope_cm.user_id', 'wrong_questions.user_id') ->where('scope_c.owner_id', $user->id) ->whereNull('scope_c.deleted_at'); }); }); } /** * @param array $filters */ private function applyWrongFilters(Builder $query, array $filters): void { $query ->when($filters['class_id'] ?? null, fn (Builder $builder, int $classId) => $builder->whereExists(function (Builder $exists) use ($classId): void { $exists->selectRaw('1') ->from('class_members as filter_cm') ->whereColumn('filter_cm.user_id', 'wrong_questions.user_id') ->where('filter_cm.class_id', $classId); })) ->when($filters['user_id'] ?? null, fn (Builder $builder, int $userId) => $builder->where('wrong_questions.user_id', $userId)) ->when($filters['question_bank_id'] ?? null, fn (Builder $builder, int $bankId) => $builder->where('q.question_bank_id', $bankId)) ->when($filters['category_id'] ?? null, fn (Builder $builder, int $categoryId) => $builder->where('q.category_id', $categoryId)) ->when($filters['tag_id'] ?? null, fn (Builder $builder, int $tagId) => $builder->whereExists(function (Builder $exists) use ($tagId): void { $exists->selectRaw('1') ->from('question_tag as filter_qt') ->whereColumn('filter_qt.question_id', 'q.id') ->where('filter_qt.question_tag_id', $tagId); })) ->when($filters['type'] ?? null, fn (Builder $builder, string $type) => $builder->where('q.type', $type)); } /** * @param array $filters */ private function questionErrorQuery(User $user, array $filters): Builder { return $this->answeredItems($user, $filters) ->selectRaw('q.id, q.content, q.type, qb.name as bank_name, coalesce(qc.name, "未分类") as category_name') ->selectRaw('count(*) as attempts') ->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count') ->selectRaw('max(case when qaq.is_correct = 0 then qaq.answered_at else null end) as last_wrong_at') ->groupBy('q.id', 'q.content', 'q.type', 'qb.name', 'qc.name') ->orderByDesc('wrong_count') ->orderByDesc('attempts'); } /** * @param array $filters * @param array $groups * @param array $extraSelects * @return array> */ private function dimensionRows(User $user, array $filters, string $idExpression, string $nameExpression, array $groups, int $limit, array $extraSelects = []): array { $query = $this->answeredItems($user, $filters) ->selectRaw($idExpression.' as id') ->selectRaw($nameExpression.' as name'); foreach ($extraSelects as $alias => $expression) { $query->selectRaw($expression.' as '.$alias); } return $query ->selectRaw('count(*) as answered_count') ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') ->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count') ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') ->groupBy(...$groups) ->orderByDesc('wrong_count') ->orderByDesc('answered_count') ->limit($limit) ->get() ->map(fn (object $row): array => $this->dimensionRow($row)) ->all(); } /** * @param array $filters * @return array> */ private function tagRows(User $user, array $filters, int $limit): array { return $this->answeredItems($user, $filters) ->join('question_tag as qt', 'qt.question_id', '=', 'q.id') ->join('question_tags as qtag', 'qtag.id', '=', 'qt.question_tag_id') ->selectRaw('qtag.id as id, qtag.name as name, qb.name as bank_name') ->selectRaw('count(*) as answered_count') ->selectRaw('coalesce(sum(case when qaq.is_correct = 1 then 1 else 0 end), 0) as correct_count') ->selectRaw('coalesce(sum(case when qaq.is_correct = 0 then 1 else 0 end), 0) as wrong_count') ->selectRaw('coalesce(avg(qaq.duration_seconds), 0) as avg_duration_seconds') ->groupBy('qtag.id', 'qtag.name', 'qb.name') ->orderByDesc('wrong_count') ->orderByDesc('answered_count') ->limit($limit) ->get() ->map(fn (object $row): array => $this->dimensionRow($row)) ->all(); } /** * @param array $filters */ private function visibleClasses(User $user, array $filters): Builder { return DB::table('classes') ->leftJoin('class_members', 'class_members.class_id', '=', 'classes.id') ->whereNull('classes.deleted_at') ->when($user->role !== 'admin', fn (Builder $query) => $query->where('classes.owner_id', $user->id)) ->when($filters['class_id'] ?? null, fn (Builder $query, int $classId) => $query->where('classes.id', $classId)) ->selectRaw('classes.id, classes.name, count(distinct class_members.user_id) as members_count') ->groupBy('classes.id', 'classes.name') ->orderBy('classes.name'); } /** * @param array $filters * @return Collection */ private function scopedStudentIds(User $user, array $filters): Collection { $ids = DB::table('quiz_attempts as qa') ->join('quiz_attempt_questions as qaq', 'qaq.quiz_attempt_id', '=', 'qa.id') ->join('questions as q', 'q.id', '=', 'qaq.question_id') ->join('question_banks as qb', 'qb.id', '=', 'q.question_bank_id') ->whereNotNull('qaq.answered_at') ->tap(fn (Builder $query) => $this->applyScope($query, $user)) ->tap(fn (Builder $query) => $this->applyFilters($query, $filters)) ->distinct() ->pluck('qa.user_id'); $classMemberQuery = DB::table('class_members') ->join('classes', 'classes.id', '=', 'class_members.class_id') ->whereNull('classes.deleted_at') ->when($user->role !== 'admin', fn (Builder $query) => $query->where('classes.owner_id', $user->id)) ->when($filters['class_id'] ?? null, fn (Builder $query, int $classId) => $query->where('class_members.class_id', $classId)); if (($filters['class_id'] ?? null) || $user->role !== 'admin') { $classIds = $classMemberQuery ->pluck('user_id'); $ids = $ids->merge($classIds); } return $ids->map(fn ($id): int => (int) $id)->unique()->values(); } private function canViewStudent(User $viewer, int $studentId): bool { if ($viewer->role === 'admin') { return true; } return DB::table('class_members') ->join('classes', 'classes.id', '=', 'class_members.class_id') ->where('class_members.user_id', $studentId) ->where('classes.owner_id', $viewer->id) ->whereNull('classes.deleted_at') ->exists(); } private function canViewBank(User $viewer, int $bankId): bool { if ($viewer->role === 'admin') { return true; } return DB::table('question_banks') ->where('id', $bankId) ->where('owner_id', $viewer->id) ->whereNull('deleted_at') ->exists(); } /** * @return array */ private function dimensionRow(object $row): array { $answered = (int) $row->answered_count; $correct = (int) $row->correct_count; $data = [ 'id' => $row->id === null ? null : (is_numeric($row->id) ? (int) $row->id : (string) $row->id), 'name' => (string) $row->name, 'answered_count' => $answered, 'correct_count' => $correct, 'wrong_count' => (int) $row->wrong_count, 'accuracy' => $this->percent($correct, $answered), 'avg_duration_seconds' => round((float) $row->avg_duration_seconds, 1), ]; if (property_exists($row, 'bank_name')) { $data['bank_name'] = $row->bank_name; } return $data; } /** * @return array */ private function questionErrorRow(object $row): array { $attempts = (int) $row->attempts; $wrong = (int) $row->wrong_count; return [ 'id' => (int) $row->id, 'content' => $row->content, 'type' => $row->type, 'bank_name' => $row->bank_name, 'category_name' => $row->category_name, 'attempts' => $attempts, 'wrong_count' => $wrong, 'wrong_rate' => $this->percent($wrong, $attempts), 'last_wrong_at' => $row->last_wrong_at, ]; } /** * @return array */ private function studentRow(object $row): array { $answered = (int) $row->answered_count; $correct = (int) $row->correct_count; return [ 'id' => (int) $row->id, 'name' => $row->name, 'email' => $row->email, 'attempts' => (int) $row->attempts, 'answered_count' => $answered, 'correct_count' => $correct, 'accuracy' => $this->percent($correct, $answered), 'avg_duration_seconds' => round((float) $row->avg_duration_seconds, 1), 'wrong_questions' => (int) $row->wrong_questions, 'last_answered_at' => $row->last_answered_at, ]; } private function percent(int|float $value, int|float $total): float { return $total > 0 ? round($value / $total * 100, 2) : 0.0; } private function nullableInt(mixed $value): ?int { if ($value === null || $value === '') { return null; } return (int) $value; } }