batch($batchId); if (!$batch || ($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') { return false; } $packageStatus = (string)($batch['package_status'] ?? 'pending'); if ($packageStatus === 'generated' && trim((string)($batch['package_path'] ?? '')) !== '') { return false; } if (in_array($packageStatus, ['pending', 'generating'], true) && trim((string)($batch['package_requested_at'] ?? '')) !== '') { return false; } if (!$this->allQrImagesGenerated($batchId, (int)$batch['total_count'])) { return false; } $now = date('Y-m-d H:i:s'); Db::name('material_batches')->where('id', $batchId)->update([ 'package_status' => 'pending', 'package_error' => '', 'package_requested_at' => $now, 'updated_at' => $now, ]); try { $sent = (bool)RedisQueueClient::send(self::QUEUE_NAME, ['batch_id' => $batchId]); if (!$sent) { throw new \RuntimeException('Redis 队列写入返回失败'); } return true; } catch (\Throwable $e) { Db::name('material_batches')->where('id', $batchId)->update([ 'package_status' => 'failed', 'package_error' => mb_substr('压缩包生成任务投递失败:' . $e->getMessage(), 0, 500), 'updated_at' => date('Y-m-d H:i:s'), ]); throw $e; } } public function generateForBatchId(int $batchId): ?array { $batch = $this->batch($batchId); if (!$batch) { return null; } if (($batch['package_status'] ?? '') === 'purged') { return $batch; } if (($batch['status'] ?? 'active') === 'invalid') { throw new \RuntimeException('物料批次已失效,不能生成压缩包'); } $now = date('Y-m-d H:i:s'); Db::name('material_batches')->where('id', $batchId)->update([ 'package_status' => 'generating', 'package_error' => '', 'updated_at' => $now, ]); try { $codes = Db::name('material_tag_codes') ->where('batch_id', $batchId) ->where('status', 'active') ->order('id', 'asc') ->select() ->toArray(); if (count($codes) !== (int)$batch['total_count']) { throw new \RuntimeException('该批次存在已失效条码,不能生成完整生产压缩包'); } $qrService = new MaterialTagQrCodeService(); foreach ($codes as $index => $row) { if ((string)($row['qr_image_status'] ?? '') !== 'generated' || trim((string)($row['qr_image_path'] ?? '')) === '' || !$qrService->isCurrentMaterialTagCard((string)$row['qr_image_path']) ) { $codes[$index] = $qrService->generateForTag($row); } } foreach ($codes as $row) { if ((string)($row['qr_image_status'] ?? '') !== 'generated' || trim((string)($row['qr_image_path'] ?? '')) === '') { throw new \RuntimeException('吊牌图片尚未全部生成'); } if (!is_file($this->resource()->publicPath((string)$row['qr_image_path']))) { throw new \RuntimeException('吊牌图片本地文件不存在:' . (string)$row['verify_code']); } } $relativePath = $this->resource()->packageRelativePath((string)$batch['batch_no']); $target = $this->resource()->publicPath($relativePath); $this->resource()->ensureParentDirectory($target); $this->buildZipFile($target, $batch, $codes); $publicUrl = $this->resource()->publicUrl($relativePath); $freshNow = date('Y-m-d H:i:s'); Db::name('material_batches')->where('id', $batchId)->update([ 'package_status' => 'generated', 'package_path' => $relativePath, 'package_url' => $publicUrl, 'package_error' => '', 'package_generated_at' => $freshNow, 'package_purged_at' => null, 'updated_at' => $freshNow, ]); $this->purgeOldBatchResources(); return Db::name('material_batches')->where('id', $batchId)->find() ?: $batch; } catch (\Throwable $e) { Db::name('material_batches')->where('id', $batchId)->update([ 'package_status' => 'failed', 'package_error' => mb_substr($e->getMessage(), 0, 500), 'updated_at' => date('Y-m-d H:i:s'), ]); throw $e; } } public function purgeOldBatchResources(): void { $keepIds = Db::name('material_batches') ->order('created_at', 'desc') ->order('id', 'desc') ->limit(self::RETAIN_BATCHES) ->column('id'); $keepIds = array_values(array_filter(array_map('intval', $keepIds))); $query = Db::name('material_batches')->where('package_status', '<>', 'purged'); if ($keepIds) { $query->whereNotIn('id', $keepIds); } $oldBatches = $query->select()->toArray(); if (!$oldBatches) { return; } $now = date('Y-m-d H:i:s'); foreach ($oldBatches as $batch) { $batchId = (int)$batch['id']; $this->resource()->deleteBatchResources((string)$batch['batch_no']); Db::name('material_batches')->where('id', $batchId)->update([ 'package_status' => 'purged', 'package_path' => '', 'package_url' => '', 'package_error' => MaterialLocalResourceService::RETENTION_MESSAGE, 'package_purged_at' => $now, 'updated_at' => $now, ]); Db::name('material_tag_codes')->where('batch_id', $batchId)->update([ 'qr_image_status' => 'purged', 'qr_image_url' => '', 'qr_image_path' => '', 'qr_image_error' => MaterialLocalResourceService::RETENTION_MESSAGE, 'updated_at' => $now, ]); } } private function allQrImagesGenerated(int $batchId, int $totalCount): bool { if ($totalCount <= 0) { return false; } $count = (int)Db::name('material_tag_codes') ->where('batch_id', $batchId) ->where('status', 'active') ->where('qr_image_status', 'generated') ->where('qr_image_url', '<>', '') ->count(); return $count === $totalCount; } private function buildZipFile(string $target, array $batch, array $codes): void { if (!class_exists(\ZipArchive::class)) { throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成压缩包'); } $zip = new \ZipArchive(); if ($zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('压缩包创建失败'); } $safeBatchNo = $this->resource()->safeName((string)$batch['batch_no'], 'batch'); $zip->addFromString(sprintf('material-batch-%s.xlsx', $safeBatchNo), $this->buildXlsxBinary($codes)); $usedNames = []; foreach ($codes as $row) { $filename = $this->uniqueQrFilename((string)$row['verify_code'], $usedNames); $zip->addFile($this->resource()->publicPath((string)$row['qr_image_path']), 'tag-cards/' . $filename); } if (!$zip->close()) { @unlink($target); throw new \RuntimeException('压缩包写入失败'); } } private function uniqueQrFilename(string $verifyCode, array &$usedNames): string { $base = $this->resource()->safeName($verifyCode, 'code'); $candidate = $base . '.png'; $index = 2; while (isset($usedNames[$candidate])) { $candidate = sprintf('%s-%d.png', $base, $index++); } $usedNames[$candidate] = true; return $candidate; } private function batch(int $batchId): ?array { return $batchId > 0 ? (Db::name('material_batches')->where('id', $batchId)->find() ?: null) : null; } private function resource(): MaterialLocalResourceService { return new MaterialLocalResourceService(); } private function buildXlsxBinary(array $rows): string { $tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_'); if ($tmpFile === false) { throw new \RuntimeException('临时文件创建失败'); } $zip = new \ZipArchive(); if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) { @unlink($tmpFile); throw new \RuntimeException('Excel 文件创建失败'); } $zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml()); $zip->addFromString('_rels/.rels', $this->xlsxRelsXml()); $zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml()); $zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml()); $zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows)); $zip->close(); $content = file_get_contents($tmpFile); @unlink($tmpFile); if ($content === false) { throw new \RuntimeException('Excel 文件读取失败'); } return $content; } private function xlsxContentTypesXml(): string { return '' . '' . '' . '' . '' . '' . ''; } private function xlsxRelsXml(): string { return '' . '' . '' . ''; } private function xlsxWorkbookXml(): string { return '' . '' . '' . ''; } private function xlsxWorkbookRelsXml(): string { return '' . '' . '' . ''; } private function xlsxSheetXml(array $rows): string { $sheetRows = [ ['吊牌图片链接', '验真编码'], ...array_map(fn (array $row) => [(string)$row['qr_image_url'], (string)$row['verify_code']], $rows), ]; $xmlRows = []; foreach ($sheetRows as $rowIndex => $row) { $excelRow = $rowIndex + 1; $xmlRows[] = sprintf( '%s%s', $excelRow, $excelRow, htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'), $excelRow, htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8') ); } return '' . '' . '' . '' . implode('', $xmlRows) . '' . ''; } }