QuickQuiz/app/Http/Controllers/Api/Admin/ReportController.php

184 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Models\ExportJob;
use App\Models\Question;
use App\Models\QuizAttempt;
use App\Models\User;
use App\Models\WrongQuestion;
use App\Support\ApiResponse;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
#[Apidoc\Group('后台')]
#[Apidoc\Title('统计报表')]
#[Apidoc\RouteMiddleware(['jwt.auth'])]
final class ReportController extends Controller
{
#[Apidoc\Title('报表概览')]
#[Apidoc\Url('/admin/reports/overview')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function overview(): JsonResponse
{
$attempts = QuizAttempt::query();
$total = (clone $attempts)->count();
$correct = (clone $attempts)->sum('correct_count');
$questions = (clone $attempts)->sum('total_questions');
return ApiResponse::success([
'users' => User::query()->count(),
'questions' => Question::query()->count(),
'attempts' => $total,
'wrong_questions' => WrongQuestion::query()->whereNull('mastered_at')->count(),
'accuracy' => $questions > 0 ? round($correct / $questions * 100, 2) : 0,
]);
}
#[Apidoc\Title('练习趋势')]
#[Apidoc\Url('/admin/reports/trends')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function trends(): JsonResponse
{
$rows = QuizAttempt::query()
->selectRaw('date(started_at) as day, count(*) as attempts, sum(correct_count) as correct_count, sum(total_questions) as total_questions')
->where('started_at', '>=', now()->subDays(14))
->groupBy('day')
->orderBy('day')
->get();
return ApiResponse::success($rows);
}
#[Apidoc\Title('题目错误率')]
#[Apidoc\Url('/admin/reports/question-errors')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function questionErrors(Request $request): JsonResponse
{
$rows = DB::table('quiz_attempt_questions')
->join('questions', 'questions.id', '=', 'quiz_attempt_questions.question_id')
->selectRaw('questions.id, questions.content, count(*) as attempts, sum(case when is_correct = 0 then 1 else 0 end) as wrong_count')
->groupBy('questions.id', 'questions.content')
->orderByDesc('wrong_count')
->paginate((int) $request->query('per_page', 20));
return ApiResponse::page($rows);
}
#[Apidoc\Title('班级排行')]
#[Apidoc\Url('/admin/reports/class-ranking')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function classRanking(Request $request): JsonResponse
{
$rows = DB::table('classes')
->leftJoin('class_members', 'class_members.class_id', '=', 'classes.id')
->leftJoin('quiz_attempts', 'quiz_attempts.user_id', '=', 'class_members.user_id')
->when($request->user()->role !== 'admin', fn ($query) => $query->where('classes.owner_id', $request->user()->id))
->selectRaw('classes.id, classes.name, count(distinct class_members.user_id) as members_count, count(distinct quiz_attempts.id) as attempts, coalesce(sum(quiz_attempts.correct_count), 0) as correct_count, coalesce(sum(quiz_attempts.total_questions), 0) as total_questions')
->groupBy('classes.id', 'classes.name')
->orderByDesc('attempts')
->limit(20)
->get()
->map(function (object $row): array {
$totalQuestions = (int) $row->total_questions;
return [
'id' => (int) $row->id,
'name' => $row->name,
'members_count' => (int) $row->members_count,
'attempts' => (int) $row->attempts,
'correct_count' => (int) $row->correct_count,
'total_questions' => $totalQuestions,
'accuracy' => $totalQuestions > 0 ? round((int) $row->correct_count / $totalQuestions * 100, 2) : 0,
];
});
return ApiResponse::success($rows);
}
#[Apidoc\Title('题库和分类掌握度')]
#[Apidoc\Url('/admin/reports/mastery')]
#[Apidoc\Method('GET')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function mastery(Request $request): JsonResponse
{
$query = DB::table('quiz_attempt_questions')
->join('quiz_attempts', 'quiz_attempts.id', '=', 'quiz_attempt_questions.quiz_attempt_id')
->join('questions', 'questions.id', '=', 'quiz_attempt_questions.question_id')
->join('question_banks', 'question_banks.id', '=', 'questions.question_bank_id')
->leftJoin('question_categories', 'question_categories.id', '=', 'questions.category_id')
->whereNotNull('quiz_attempt_questions.is_correct')
->when($request->user()->role !== 'admin', fn ($builder) => $builder->where('question_banks.owner_id', $request->user()->id));
$banks = (clone $query)
->selectRaw('question_banks.id, question_banks.name, count(*) as attempts, sum(case when quiz_attempt_questions.is_correct = 1 then 1 else 0 end) as correct_count')
->groupBy('question_banks.id', 'question_banks.name')
->orderByDesc('attempts')
->limit(20)
->get()
->map(fn (object $row): array => $this->masteryRow($row));
$categories = (clone $query)
->selectRaw('question_banks.name as bank_name, question_categories.id, coalesce(question_categories.name, "未分类") as name, count(*) as attempts, sum(case when quiz_attempt_questions.is_correct = 1 then 1 else 0 end) as correct_count')
->groupBy('question_banks.name', 'question_categories.id', 'question_categories.name')
->orderByDesc('attempts')
->limit(30)
->get()
->map(fn (object $row): array => $this->masteryRow($row) + ['bank_name' => $row->bank_name]);
return ApiResponse::success([
'banks' => $banks,
'categories' => $categories,
]);
}
#[Apidoc\Title('报表导出')]
#[Apidoc\Url('/admin/reports/export')]
#[Apidoc\Method('POST')]
#[Apidoc\RouteMiddleware(['permission:reports'])]
public function export(Request $request): JsonResponse
{
$payload = [
'overview' => $this->overview()->getData(true)['data'],
'trends' => $this->trends()->getData(true)['data'],
'class_ranking' => $this->classRanking($request)->getData(true)['data'],
'mastery' => $this->mastery($request)->getData(true)['data'],
];
$path = 'exports/report-'.now()->format('YmdHis').'.json';
Storage::put($path, json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
$job = ExportJob::create([
'user_id' => $request->user()->id,
'type' => 'report',
'file_path' => $path,
'payload' => $payload,
]);
return ApiResponse::success($job, '报表已导出');
}
private function masteryRow(object $row): array
{
$attempts = (int) $row->attempts;
$correct = (int) $row->correct_count;
return [
'id' => $row->id === null ? null : (int) $row->id,
'name' => $row->name,
'attempts' => $attempts,
'correct_count' => $correct,
'accuracy' => $attempts > 0 ? round($correct / $attempts * 100, 2) : 0,
];
}
}