QuickQuiz/app/Services/QuestionImportService.php

375 lines
13 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\ImportJob;
use App\Models\Question;
use App\Models\QuestionBank;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Maatwebsite\Excel\Concerns\ToArray;
use Maatwebsite\Excel\Facades\Excel;
final class QuestionImportService
{
public function importJsonText(QuestionBank $bank, User $user, string $json, ?string $filePath = null): ImportJob
{
$rows = json_decode($json, true);
if (! is_array($rows)) {
throw ValidationException::withMessages(['file' => 'JSON 必须是题目数组']);
}
return $this->importRows($bank, $user, $rows, 'json', $filePath);
}
public function importUploadedFile(QuestionBank $bank, User $user, UploadedFile $file): ImportJob
{
$prepared = $this->prepareUploadedFile($file);
return $this->importRows($bank, $user, $prepared['rows'], $prepared['type'], $prepared['path']);
}
/**
* @return array{type:string,path:string,rows:array<int, array<string, mixed>>}
*/
public function prepareUploadedFile(UploadedFile $file): array
{
$path = $file->store('imports');
$extension = strtolower($file->getClientOriginalExtension());
if ($extension === 'json') {
$content = Storage::get($path);
$rows = json_decode($content, true);
if (! is_array($rows)) {
throw ValidationException::withMessages(['file' => 'JSON 必须是题目数组']);
}
return ['type' => 'json', 'path' => $path, 'rows' => $rows];
}
$sheets = Excel::toArray(new class implements ToArray
{
public function array(array $array): array
{
return $array;
}
}, Storage::path($path));
$rows = $this->normalizeExcelRows($sheets[0] ?? []);
return ['type' => 'excel', 'path' => $path, 'rows' => $rows];
}
/**
* @param array<int, array<string, mixed>> $rows
* @return array{valid:bool,rows:array<int, array<string, mixed>>,errors:array<int, array{row:int,message:string}>,duplicates:array<int, array{row:int,message:string,content:string,type:string}>,importable_count:int}
*/
public function validateRows(array $rows, ?QuestionBank $bank = null): array
{
$errors = [];
$duplicates = [];
$seenHashes = [];
foreach ($rows as $index => $row) {
try {
$normalized = $this->normalizeQuestionRow($row, $index + 1);
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
if (isset($seenHashes[$hash])) {
$duplicates[] = [
'row' => $index + 1,
'message' => "与本次上传第 {$seenHashes[$hash]} 行重复,确认导入时会跳过",
'content' => $normalized['content'],
'type' => $normalized['type'],
];
continue;
}
$seenHashes[$hash] = $index + 1;
if ($bank !== null && $this->isDuplicate($bank, $normalized)) {
$duplicates[] = [
'row' => $index + 1,
'message' => '与题库已有题目重复,确认导入时会跳过',
'content' => $normalized['content'],
'type' => $normalized['type'],
];
}
} catch (ValidationException $exception) {
$errors[] = [
'row' => $index + 1,
'message' => collect($exception->errors())->flatten()->first() ?? '格式错误',
];
}
}
return [
'valid' => $errors === [],
'rows' => $rows,
'errors' => $errors,
'duplicates' => $duplicates,
'importable_count' => max(0, count($rows) - count($errors) - count($duplicates)),
];
}
/**
* @param array<int, array<string, mixed>> $rows
*/
public function importRows(QuestionBank $bank, User $user, array $rows, string $type, ?string $filePath = null): ImportJob
{
return DB::transaction(function () use ($bank, $user, $rows, $type, $filePath): ImportJob {
$job = ImportJob::create([
'user_id' => $user->id,
'question_bank_id' => $bank->id,
'type' => $type,
'file_path' => $filePath,
'status' => 'running',
'total_count' => count($rows),
'report' => [],
]);
$report = [];
$success = 0;
$skipped = 0;
$seenHashes = [];
foreach ($rows as $index => $row) {
$normalized = $this->normalizeQuestionRow($row, $index + 1);
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
if (isset($seenHashes[$hash])) {
$skipped++;
$report[] = [
'row' => $index + 1,
'status' => 'skipped',
'message' => "与本次导入第 {$seenHashes[$hash]} 行重复,已跳过",
'content' => $normalized['content'],
'type' => $normalized['type'],
];
continue;
}
$seenHashes[$hash] = $index + 1;
if ($this->isDuplicate($bank, $normalized)) {
$skipped++;
$report[] = [
'row' => $index + 1,
'status' => 'skipped',
'message' => '与题库已有题目重复,已跳过',
'content' => $normalized['content'],
'type' => $normalized['type'],
];
continue;
}
$question = Question::create([
'question_bank_id' => $bank->id,
'category_id' => null,
'creator_id' => $user->id,
'type' => $normalized['type'],
'content' => $normalized['content'],
'explanation' => $normalized['explanation'],
'answers' => $normalized['answers'],
'source_question_id' => $normalized['source_question_id'],
'dedup_hash' => $hash,
'is_active' => true,
]);
foreach ($normalized['options'] as $sort => $option) {
$question->options()->create([
'content' => $option['text'],
'is_correct' => $option['correct'],
'sort' => $sort,
]);
}
$success++;
$report[] = [
'row' => $index + 1,
'status' => 'success',
'message' => '导入成功',
'content' => $normalized['content'],
'type' => $normalized['type'],
];
}
$job->update([
'status' => 'finished',
'success_count' => $success,
'skipped_count' => $skipped,
'report' => $report,
]);
return $job->fresh();
});
}
/**
* @param array<string, mixed> $row
* @return array{type:string,content:string,explanation:?string,answers:array<int, mixed>,source_question_id:?string,options:array<int, array{text:string,correct:bool}>}
*/
private function normalizeQuestionRow(array $row, int $rowNumber): array
{
$content = trim((string) ($row['questionText'] ?? $row['content'] ?? $row['题干'] ?? ''));
if ($content === '') {
throw ValidationException::withMessages(['file' => "{$rowNumber} 行题干不能为空"]);
}
$options = $row['options'] ?? null;
if (! is_array($options)) {
$options = $this->optionsFromFlatRow($row);
}
$normalizedOptions = [];
foreach ($options as $option) {
if (! is_array($option)) {
continue;
}
$text = trim((string) ($option['text'] ?? $option['content'] ?? $option['选项'] ?? ''));
if ($text === '') {
continue;
}
$normalizedOptions[] = [
'text' => $text,
'correct' => (bool) ($option['correct'] ?? $option['is_correct'] ?? false),
];
}
$correctCount = count(array_filter($normalizedOptions, fn (array $option): bool => $option['correct']));
if ($correctCount < 1 && empty($row['answer'])) {
throw ValidationException::withMessages(['file' => "{$rowNumber} 行至少需要一个正确答案"]);
}
$type = $this->detectType($normalizedOptions, $correctCount, (string) ($row['type'] ?? ''));
if ($type === 'blank') {
$answers = array_values(array_filter(array_map('trim', explode('|', (string) ($row['answer'] ?? '')))));
if ($answers === []) {
throw ValidationException::withMessages(['file' => "{$rowNumber} 行填空题答案不能为空"]);
}
return [
'type' => 'blank',
'content' => $content,
'explanation' => $row['explanation'] ?? null,
'answers' => $answers,
'source_question_id' => isset($row['questionId']) ? (string) $row['questionId'] : null,
'options' => [],
];
}
return [
'type' => $type,
'content' => $content,
'explanation' => $row['explanation'] ?? null,
'answers' => [],
'source_question_id' => isset($row['questionId']) ? (string) $row['questionId'] : null,
'options' => $normalizedOptions,
];
}
/**
* @param array<string, mixed> $row
* @return array<int, array{text:string,correct:bool}>
*/
private function optionsFromFlatRow(array $row): array
{
$answer = strtoupper(trim((string) ($row['answer'] ?? $row['答案'] ?? '')));
$letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
$correctLetters = array_filter(array_map('trim', preg_split('/[,|]/', $answer) ?: []));
$options = [];
foreach ($letters as $letter) {
$text = $row[$letter] ?? $row['option_'.$letter] ?? $row['选项'.$letter] ?? null;
if ($text === null || trim((string) $text) === '') {
continue;
}
$options[] = [
'text' => trim((string) $text),
'correct' => in_array($letter, $correctLetters, true),
];
}
return $options;
}
/**
* @param array<int, array{text:string,correct:bool}> $options
*/
private function detectType(array $options, int $correctCount, string $explicit): string
{
$explicit = strtolower($explicit);
if (in_array($explicit, ['single', 'multiple', 'judge', 'blank'], true)) {
return $explicit;
}
if ($options === []) {
return 'blank';
}
$texts = array_map(fn (array $option): string => $option['text'], $options);
sort($texts);
if (count($options) === 2 && $texts === ['对', '错']) {
return 'judge';
}
return $correctCount > 1 ? 'multiple' : 'single';
}
/**
* @param array<int, array{text:string,correct:bool}> $options
*/
private function dedupHash(string $content, array $options): string
{
return hash('sha256', json_encode([
'content' => preg_replace('/\s+/u', '', $content),
'options' => array_map(fn (array $option): array => [
'text' => preg_replace('/\s+/u', '', $option['text']),
'correct' => $option['correct'],
], $options),
], JSON_UNESCAPED_UNICODE));
}
/**
* @param array{content:string,options:array<int, array{text:string,correct:bool}>} $normalized
*/
private function isDuplicate(QuestionBank $bank, array $normalized): bool
{
return Question::query()
->where('question_bank_id', $bank->id)
->where('dedup_hash', $this->dedupHash($normalized['content'], $normalized['options']))
->exists();
}
/**
* @param array<int, array<int, mixed>> $rows
* @return array<int, array<string, mixed>>
*/
private function normalizeExcelRows(array $rows): array
{
if ($rows === []) {
return [];
}
$headers = array_map(fn ($header): string => trim((string) $header), array_shift($rows));
return array_values(array_filter(array_map(function (array $row) use ($headers): array {
$item = [];
foreach ($headers as $index => $header) {
if ($header !== '') {
$item[$header] = $row[$index] ?? null;
}
}
return $item;
}, $rows), fn (array $row): bool => array_filter($row) !== []));
}
}