QuickQuiz/app/Services/QuestionImportService.php

315 lines
11 KiB
PHP
Raw 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}>}
*/
public function validateRows(array $rows): array
{
$errors = [];
foreach ($rows as $index => $row) {
try {
$this->normalizeQuestionRow($row, $index + 1);
} catch (ValidationException $exception) {
$errors[] = [
'row' => $index + 1,
'message' => collect($exception->errors())->flatten()->first() ?? '格式错误',
];
}
}
return [
'valid' => $errors === [],
'rows' => $rows,
'errors' => $errors,
];
}
/**
* @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;
foreach ($rows as $index => $row) {
$normalized = $this->normalizeQuestionRow($row, $index + 1);
$hash = $this->dedupHash($normalized['content'], $normalized['options']);
$exists = Question::query()
->where('question_bank_id', $bank->id)
->where('dedup_hash', $hash)
->exists();
if ($exists) {
$skipped++;
$report[] = ['row' => $index + 1, 'status' => 'skipped', 'message' => '重复题目已跳过'];
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' => '导入成功'];
}
$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<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) !== []));
}
}