375 lines
13 KiB
PHP
375 lines
13 KiB
PHP
<?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) !== []));
|
||
}
|
||
}
|