'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>} */ 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> $rows * @return array{valid:bool,rows:array>,errors:array,duplicates:array,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> $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 $row * @return array{type:string,content:string,explanation:?string,answers:array,source_question_id:?string,options:array} */ 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 $row * @return array */ 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 $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 $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} $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> $rows * @return array> */ 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) !== [])); } }