只写一个 A1,靠它判断行数会把整表读成空; * - 一行订单一个商品明细,单文件常见上万行 × 上百列,解压后几十 MB。 * * 所以这里用 ZipArchive 取压缩流 + expat 增量解析,只保留调用方点名的列, * 内存占用与文件大小无关(导出 20 万行也不会整表进内存)。 */ class JstOrderExportReader { private const READ_CHUNK = 262144; /** * 逐行读取导出文件(跳过表头行)。 * * @param list $columns 需要取出并作为返回键的表头名,必须与文件第一行完全一致 * @return Generator> */ public function rows(string $path, array $columns): Generator { if (! is_file($path)) { throw new RuntimeException('订单导出文件不存在:' . $path); } $zip = new ZipArchive(); if ($zip->open($path) !== true) { throw new RuntimeException('无法打开订单导出文件(不是有效的 xlsx):' . $path); } $stream = $zip->getStream($this->worksheetPath($zip)); if ($stream === false) { $zip->close(); throw new RuntimeException('订单导出文件里没有可读的工作表。'); } try { yield from $this->parseSheet($stream, $this->sharedStrings($zip), $columns); } finally { fclose($stream); $zip->close(); } } /** * @param resource $stream * @param list $shared * @param list $columns * @return Generator> */ private function parseSheet($stream, array $shared, array $columns): Generator { $state = [ 'row' => [], 'column' => -1, 'type' => null, 'value' => '', 'inValue' => false, 'rowNumber' => 0, 'wanted' => [], 'pending' => [], ]; $parser = xml_parser_create('UTF-8'); xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0); xml_set_element_handler( $parser, static function ($parser, string $name, array $attributes) use (&$state): void { switch ($name) { case 'row': $state['row'] = []; $state['column'] = -1; break; case 'c': $state['column'] = self::columnIndex($attributes['r'] ?? null, $state['column'] + 1); $state['type'] = $attributes['t'] ?? null; $state['value'] = ''; break; case 'v': case 't': $state['inValue'] = true; break; } }, static function ($parser, string $name) use (&$state, $shared, $columns): void { switch ($name) { case 'v': case 't': $state['inValue'] = false; break; case 'c': $state['row'][$state['column']] = self::cellValue($state['type'], $state['value'], $shared); break; case 'row': $state['rowNumber']++; if ($state['rowNumber'] === 1) { $state['wanted'] = self::wantedColumns($state['row'], $columns); break; } $values = []; foreach ($state['wanted'] as $index => $column) { $values[$column] = $state['row'][$index] ?? ''; } $state['pending'][] = $values; break; } } ); xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$state): void { if ($state['inValue']) { $state['value'] .= $data; } }); $first = true; try { while (! feof($stream)) { $chunk = fread($stream, self::READ_CHUNK); if ($chunk === false || $chunk === '') { break; } if ($first) { // 聚水潭导出的 XML 带 UTF-8 BOM,expat 会当成非法前缀。 $chunk = preg_replace('/^\xEF\xBB\xBF/', '', $chunk, 1); $first = false; } if (xml_parse($parser, $chunk, false) === 0) { throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser))); } while ($state['pending'] !== []) { yield array_shift($state['pending']); } } if (xml_parse($parser, '', true) === 0) { throw new RuntimeException('订单导出文件解析失败:' . xml_error_string(xml_get_error_code($parser))); } } finally { xml_parser_free($parser); } while ($state['pending'] !== []) { yield array_shift($state['pending']); } } /** * 表头行 -> [列下标 => 列名],并校验需要的列都在。 * * @param array $header * @param list $columns * @return array */ private static function wantedColumns(array $header, array $columns): array { $byName = []; foreach ($header as $index => $name) { $name = trim($name); if ($name !== '' && ! isset($byName[$name])) { $byName[$name] = $index; } } $wanted = []; foreach ($columns as $column) { if (! isset($byName[$column])) { throw new RuntimeException('订单导出文件缺少列:' . $column); } $wanted[$byName[$column]] = $column; } return $wanted; } /** * @param list $shared */ private static function cellValue(?string $type, string $value, array $shared): string { if ($type === 's') { return $shared[(int) $value] ?? ''; } return trim($value); } /** 单元格引用(如 AV12)-> 0 基列下标。 */ private static function columnIndex(?string $reference, int $fallback): int { if ($reference === null || preg_match('/^([A-Z]+)/i', $reference, $matches) !== 1) { return $fallback; } $letters = strtoupper($matches[1]); $index = 0; for ($i = 0, $length = strlen($letters); $i < $length; $i++) { $index = $index * 26 + (ord($letters[$i]) - 64); } return $index - 1; } private function worksheetPath(ZipArchive $zip): string { $workbook = $zip->getFromName('xl/workbook.xml'); $relationships = $zip->getFromName('xl/_rels/workbook.xml.rels'); if (is_string($workbook) && is_string($relationships) && preg_match('/]*r:id="([^"]+)"/', $workbook, $sheet) === 1 && preg_match('/Id="' . preg_quote($sheet[1], '/') . '"[^>]*Target="([^"]+)"/', $relationships, $target) === 1) { $path = ltrim($target[1], '/'); return str_starts_with($path, 'xl/') ? $path : 'xl/' . $path; } for ($i = 0; $i < $zip->numFiles; $i++) { $name = (string) $zip->getNameIndex($i); if (preg_match('#^xl/worksheets/[^/]+\.xml$#', $name) === 1) { return $name; } } return 'xl/worksheets/sheet1.xml'; } /** * 读取共享字符串表;聚水潭导出通常为空,但标准 xlsx 会用到。 * * @return list */ private function sharedStrings(ZipArchive $zip): array { $xml = $zip->getFromName('xl/sharedStrings.xml'); if (! is_string($xml) || $xml === '') { return []; } $out = []; $index = -1; $buffer = ''; $inside = false; $parser = xml_parser_create('UTF-8'); xml_parser_set_option($parser, XML_OPTION_CASE_FOLDING, 0); xml_set_element_handler( $parser, static function ($parser, string $name) use (&$inside, &$buffer, &$index): void { if ($name === 'si') { $index++; $buffer = ''; } elseif ($name === 't') { $inside = true; } }, static function ($parser, string $name) use (&$inside, &$buffer, &$out): void { if ($name === 't') { $inside = false; } elseif ($name === 'si') { $out[] = $buffer; } } ); xml_set_character_data_handler($parser, static function ($parser, string $data) use (&$inside, &$buffer): void { if ($inside) { $buffer .= $data; } }); try { xml_parse($parser, preg_replace('/^\xEF\xBB\xBF/', '', $xml, 1), true); } finally { xml_parser_free($parser); } return $out; } }