$fields 该表的字段定义(决定表头) * @param string $storeDataset 「终版」目录实际保存记录用的数据集键 * @return array{created:int,updated:int,rows:int} */ public function import(array $fields, string $storeDataset, UploadedFile $file, int $companyId, ?int $userId): array { $lookup = ['记录标识' => '_action_key', 'recordkey' => '_action_key', '_action_key' => '_action_key']; foreach ($fields as $field => $definition) { $lookup[$this->header($field)] = $field; $lookup[$this->header($definition['label'])] = $field; } try { $readerType = match (mb_strtolower($file->getClientOriginalExtension())) { 'xlsx' => 'Xlsx', 'xls' => 'Xls', 'csv' => 'Csv', default => throw new \RuntimeException('Unsupported workbook format.'), }; $header = $this->locateHeader($readerType, $file->getRealPath(), $lookup); } catch (Throwable) { throw ValidationException::withMessages([ 'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', ]); } if ($header === null) { throw ValidationException::withMessages([ 'import' => '没有识别到表头,请先下载本页面的导入模板,按模板列填写后重试。', ]); } [$sheetIndex, $headerRow, $columns] = $header; $store = $this->store; $records = []; foreach ($this->rowsAfter($readerType, $file->getRealPath(), $sheetIndex, $headerRow + 1) as $line => $row) { $key = ''; $rawValues = []; foreach ($columns as $columnIndex => $field) { if ($field === '_action_key') { $key = trim((string) ($row[$columnIndex] ?? '')); } else { $rawValues[$field] = $row[$columnIndex] ?? null; } } if (collect($rawValues)->every(static fn (mixed $value): bool => trim((string) $value) === '')) { continue; } try { $values = $this->normalizer->normalize($fields, $rawValues); } catch (ValidationException $exception) { throw ValidationException::withMessages([ 'import' => "第 {$line} 行:" . collect($exception->errors())->flatten()->first(), ]); } if ($key === '') { $key = $store->key($storeDataset, $values); } if (isset($records[$key])) { throw ValidationException::withMessages(['import' => "第 {$line} 行与前一行内容重复。"]); } $records[$key] = $values; if (count($records) > self::MAX_ROWS) { throw ValidationException::withMessages(['import' => '单次最多导入 10,000 条记录,请拆分文件后重试。']); } } if ($records === []) { throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']); } $counts = ['created' => 0, 'updated' => 0]; $store->transaction(function () use ($store, $records, $storeDataset, $companyId, $userId, &$counts): void { foreach ($records as $key => $values) { $result = $store->save($storeDataset, $key, $values, $companyId, $userId, 'import'); $counts['created'] += $result['created']; $counts['updated'] += $result['updated']; } }); return $counts + ['rows' => count($records)]; } /** * 载入第一块行,在其中找到表头。 * * @param array $lookup * @return array{0:int,1:int,2:array}|null [sheet 序号, 表头行号(1 基), 列序号 => 字段] */ private function locateHeader(string $readerType, string $path, array $lookup): ?array { $best = null; $score = 0; $spreadsheet = $this->loadChunk($readerType, $path, 1, self::CHUNK_ROWS); try { foreach ($spreadsheet->getWorksheetIterator() as $sheetIndex => $worksheet) { foreach ($worksheet->getRowIterator(1, self::HEADER_SCAN_ROWS) as $row) { $columns = []; foreach ($row->getCellIterator() as $cell) { $key = $this->header($cell->getValue()); if ($key !== '' && isset($lookup[$key])) { $columns[Coordinate::columnIndexFromString($cell->getColumn()) - 1] = $lookup[$key]; } } if (count($columns) > $score) { $score = count($columns); $best = [$sheetIndex, $row->getRowIndex(), $columns]; } } } } finally { $spreadsheet->disconnectWorksheets(); } return $best; } /** * 从指定行开始按块读取数据行。 * * @return \Generator> 行号(1 基)=> 列序号 => 单元格值 */ private function rowsAfter(string $readerType, string $path, int $sheetIndex, int $firstRow): \Generator { for ($start = $firstRow; ; $start += self::CHUNK_ROWS) { $spreadsheet = $this->loadChunk($readerType, $path, $start, self::CHUNK_ROWS); try { $worksheet = $spreadsheet->getSheet($sheetIndex); $highestRow = $worksheet->getHighestRow(); if ($highestRow < $start) { return; } $endRow = min($start + self::CHUNK_ROWS - 1, $highestRow); foreach ($worksheet->getRowIterator($start, $endRow) as $row) { $cells = []; foreach ($row->getCellIterator() as $cell) { $cells[Coordinate::columnIndexFromString($cell->getColumn()) - 1] = $cell->getValue(); } yield $row->getRowIndex() => $cells; } } finally { $spreadsheet->disconnectWorksheets(); } } } /** 只把 [startRow, endRow] 这些行载入内存。 */ private function loadChunk(string $readerType, string $path, int $startRow, int $rows): Spreadsheet { $reader = IOFactory::createReader($readerType); $reader->setReadDataOnly(true); $reader->setReadFilter(new class($startRow, $startRow + $rows - 1) implements IReadFilter { public function __construct( private readonly int $startRow, private readonly int $endRow, ) { } public function readCell($columnAddress, $row, $worksheetName = ''): bool { return $row >= $this->startRow && $row <= $this->endRow; } }); return $reader->load($path); } private function header(mixed $value): string { return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value))); } }