From 628429af66f6c11bfa015d35c97589d07bfad638 Mon Sep 17 00:00:00 2001 From: mzl Date: Wed, 10 Dec 2025 20:18:46 +0800 Subject: [PATCH 01/14] Merge branch 'develop' --- .gitignore | 2 + app/command/InquiryAssignInquiryCommand.php | 161 ++ app/command/InquiryFbLeadCommand.php | 164 ++ app/command/InquiryImportFbCommand.php | 256 +++ app/command/VehMoneyCommand.php | 29 + .../UsedcarCrawlResultsController.php | 33 + .../UsedcarCrawlResultsImagesController.php | 33 + .../logic/UsedcarCrawlResultsImagesLogic.php | 17 + app/common/logic/UsedcarCrawlResultsLogic.php | 17 + app/common/model/UsedcarCrawlResults.php | 49 + .../model/UsedcarCrawlResultsImages.php | 49 + .../UsedcarCrawlResultsImagesService.php | 31 + .../service/UsedcarCrawlResultsService.php | 31 + .../utils/crawl/service/UsedCarCrawl.php | 857 ++++++++++ .../utils/crawl/service/UsedCarCrawl_bak.php | 681 ++++++++ .../utils/crawl/service/UsedCarCrawl_bak2.php | 798 ++++++++++ .../UsedcarCrawlResultsImagesValidate.php | 29 + .../validate/UsedcarCrawlResultsValidate.php | 29 + app/controller/IndexController.php | 27 +- app/inquiry/utils/FbLeadHeaderMapper.php | 154 ++ app/inquiry/utils/PhoneCountryHelper.php | 58 + app/inquiry/utils/RoundRobinAssigner.php | 46 + app/process/CheckWeb.php | 6 + app/process/Task.php | 2 +- composer.json | 3 +- composer.lock | 1382 ++++++++++------- config/plugin/webman/log/app.php | 19 + config/plugin/webman/log/middleware.php | 21 + 28 files changed, 4436 insertions(+), 548 deletions(-) create mode 100644 app/command/InquiryAssignInquiryCommand.php create mode 100644 app/command/InquiryFbLeadCommand.php create mode 100644 app/command/InquiryImportFbCommand.php create mode 100644 app/command/VehMoneyCommand.php create mode 100644 app/common/controller/UsedcarCrawlResultsController.php create mode 100644 app/common/controller/UsedcarCrawlResultsImagesController.php create mode 100644 app/common/logic/UsedcarCrawlResultsImagesLogic.php create mode 100644 app/common/logic/UsedcarCrawlResultsLogic.php create mode 100644 app/common/model/UsedcarCrawlResults.php create mode 100644 app/common/model/UsedcarCrawlResultsImages.php create mode 100644 app/common/service/UsedcarCrawlResultsImagesService.php create mode 100644 app/common/service/UsedcarCrawlResultsService.php create mode 100644 app/common/utils/crawl/service/UsedCarCrawl.php create mode 100644 app/common/utils/crawl/service/UsedCarCrawl_bak.php create mode 100644 app/common/utils/crawl/service/UsedCarCrawl_bak2.php create mode 100644 app/common/validate/UsedcarCrawlResultsImagesValidate.php create mode 100644 app/common/validate/UsedcarCrawlResultsValidate.php create mode 100644 app/inquiry/utils/FbLeadHeaderMapper.php create mode 100644 app/inquiry/utils/PhoneCountryHelper.php create mode 100644 app/inquiry/utils/RoundRobinAssigner.php create mode 100644 config/plugin/webman/log/app.php create mode 100644 config/plugin/webman/log/middleware.php diff --git a/.gitignore b/.gitignore index 92bf9b0..224ad2d 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ /windows.php /bootstrap.php /composer.lock +.env_copy +/public/excel \ No newline at end of file diff --git a/app/command/InquiryAssignInquiryCommand.php b/app/command/InquiryAssignInquiryCommand.php new file mode 100644 index 0000000..adb8309 --- /dev/null +++ b/app/command/InquiryAssignInquiryCommand.php @@ -0,0 +1,161 @@ + $v > 0 + )); + + if (empty($userIds)) { + throw new BusinessException('SALES_USER_IDS 业务员列表不能为空,请先在命令中配置.'); + } + + // 取出“待分配”的询盘(你可以根据自己系统调整条件) + // 当前策略:status = 0 且 recipient = 0 的视为未分配 + $inquiries = Db::table('inquiry_log') + ->where('enter_user_id', 186) + ->where('channel', 10) + ->where('status', 0) + ->where('recipient', 0) + ->where('id', '>', 34426) + ->orderBy('id') + ->limit(static::BATCH_LIMIT) + ->get(); + + if ($inquiries->isEmpty()) { + $output->writeln('没有需要分配的询盘(status=0 且 recipient=0)'); + return self::SUCCESS; + } + + $now = time(); + $count = 0; + + foreach ($inquiries as $row) { + $inquiryId = (int)$row->id; + sleep(1); + // 轮询取出一个业务员 ID + $recipientUserId = $this->nextRecipientId($userIds, static::ROUND_ROBIN_GROUP_KEY); + + // 对每一条记录单独事务,避免单条异常拖垮全部 + Db::transaction(function () use ($inquiryId, $recipientUserId, $now, $output, &$count) { + // 1)更新 inquiry_log + $affected = Db::table('inquiry_log') + ->where('id', $inquiryId) + ->update([ + 'recipient' => $recipientUserId, + 'status' => 2, + 'r_time' => $now, + 't_time' => $now, + 'updated_at' => $now, + ]); + + if ($affected === 0) { + // 被其他进程抢先更新也有可能,这里只打印提示不中断整个批次 + $output->writeln("询盘 {$inquiryId} 未更新(可能已被处理)"); + return; + } + + // 2)构造队列 payload(后续 CRM 消费时至少知道 ID) + $res = [ + 'id' => $inquiryId, + 'recipient' => $recipientUserId, + 'dispatched_at' => $now, + 'source' => 'fb_leads_inquiry', + ]; + + // 3)推送到 CRM 队列 + RedisQueueRedis::send('inquiry-to-crm-new', $res); + + $count++; + $output->writeln("已分配询盘 {$inquiryId} 给业务员 {$recipientUserId},并推送到队列"); + }); + } + + $output->writeln("本次共分配成功 {$count} 条询盘"); + + return self::SUCCESS; + } + + /** + * 轮询获取“下一位业务员 ID” + * + * @param int[] $userIds + * @param string $groupKey + * @return int + * @throws BusinessException + */ + protected function nextRecipientId(array $userIds, string $groupKey = 'default'): int + { + $userIds = array_values(array_filter(array_map('intval', $userIds), fn($v) => $v > 0)); + if (empty($userIds)) { + throw new BusinessException('轮询分配失败:业务员列表为空'); + } + + $count = count($userIds); + $key = 'inquiry:assign:round_robin:' . $groupKey; + + // Redis INCR 是原子操作,适合并发下使用 + $val = Redis::incr($key); // 第一次会从 0 变成 1 + + // 换算为从 0 开始的下标 + $index = ($val - 1) % $count; + + return $userIds[$index]; + } +} diff --git a/app/command/InquiryFbLeadCommand.php b/app/command/InquiryFbLeadCommand.php new file mode 100644 index 0000000..aec2199 --- /dev/null +++ b/app/command/InquiryFbLeadCommand.php @@ -0,0 +1,164 @@ +where('sync_status', 0) + ->get(); + + if ($leads->isEmpty()) { + $output->writeln('没有需要同步的 fb_leads(sync_status = 0)'); + return self::SUCCESS; + } + + $now = time(); + + Db::transaction(function () use ($leads, $now, $output) { + $insert = []; + $ids = []; + + foreach ($leads as $lead) { + $leadArr = (array)$lead; + $insert[] = $this->mapLeadToInquiry($leadArr, $now); + // 假设 fb_leads 的主键字段名为 id + $ids[] = $leadArr['id']; + } + + if ($insert) { + // 1) 写入 inquiry_log + Db::table('inquiry_log')->insert($insert); + + // 2) 回写 fb_leads 的同步状态 + Db::table('fb_leads') + ->whereIn('id', $ids) + ->update([ + 'sync_status' => 1, + 'updated_at' => $now, + ]); + + $output->writeln('已导入 ' . count($insert) . ' 条数据到 inquiry_log,并更新 fb_leads.sync_status = 1'); + } + }); + + return self::SUCCESS; + } + + /** + * 将一条 fb_leads 记录映射为 inquiry_log 记录 + */ + protected function mapLeadToInquiry(array $lead, int $now): array + { + // 联系方式 + $email = (string)($lead['email'] ?? ''); + $tel = (string)($lead['phone'] ?? ''); + $wa = $tel; // 先用电话号码当 WhatsApp + + // 根据电话或 WhatsApp 区号识别国家 + $countryId = PhoneCountryHelper::detectCountryId($tel ?: $wa); + + // 所有默认值按你的要求设置 + $data = [ + 'order_no' => $this->orderNum(), + 'enter_user_id' => static::DEFAULT_ENTER_USER_ID, + 'recipient' => 0, + 'quality' => 2, + 'channel' => static::DEFAULT_CHANNEL, + + 'email' => $email, + 'tel' => $tel, + 'whatsapp' => $wa, + 'country' => $countryId ?: 0, + 'contacts' => (string)($lead['full_name'] ?? ''), + + 'content' => $this->buildContentHtml($lead), + + 'status' => 0, + 'r_time' => 0, + 't_time' => 0, + 'sync_crm' => 0, + + 'created_at' => $now, + 'updated_at' => $now, + ]; + + return $data; + } + + /** + * 生成订单号 + */ + protected function orderNum(): string + { + return 'SN' . date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT); + } + + /** + * 根据 fb_leads 的字段拼出富文本内容 + */ + protected function buildContentHtml(array $lead): string + { + $e = function ($v) { + return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + }; + + $lines = []; + + if (!empty($lead['car_model'])) { + $lines[] = '

意向车型:' . $e($lead['car_model']) . '

'; + } + if (!empty($lead['car_quantity']) || !empty($lead['car_quantity_text'])) { + $qty = $lead['car_quantity'] ?? $lead['car_quantity_text'] ?? ''; + $lines[] = '

意向数量:' . $e($qty) . '

'; + } + if (!empty($lead['favorite_color'])) { + $lines[] = '

意向颜色:' . $e($lead['favorite_color']) . '

'; + } + if (!empty($lead['usage_type'])) { + $lines[] = '

用途:' . $e($lead['usage_type']) . '

'; + } + if (!empty($lead['seat_preference'])) { + $lines[] = '

座位数偏好:' . $e($lead['seat_preference']) . '

'; + } + + $lines[] = '
'; + $lines[] = '

广告与表单信息:

'; + $lines[] = '

平台:' . $e($lead['platform'] ?? '') . '

'; + $lines[] = '

Campaign:' . $e($lead['campaign_name'] ?? '') . ' (#' . $e($lead['campaign_id'] ?? '') . ')

'; + $lines[] = '

Adset:' . $e($lead['adset_name'] ?? '') . ' (#' . $e($lead['adset_id'] ?? '') . ')

'; + $lines[] = '

Ad:' . $e($lead['ad_name'] ?? '') . ' (#' . $e($lead['ad_id'] ?? '') . ')

'; + $lines[] = '

Form:' . $e($lead['form_name'] ?? '') . ' (#' . $e($lead['form_id'] ?? '') . ')

'; + + $lines[] = '
'; + $lines[] = '

Meta 信息:

'; + $lines[] = '

Lead ID:' . $e($lead['fb_lead_id'] ?? '') . '

'; + $lines[] = '

FB 创建时间原始值:' . $e($lead['fb_created_time'] ?? '') . '

'; + + if (!empty($lead['inbox_url'])) { + $url = $e($lead['inbox_url']); + $lines[] = '

Inbox 链接:' . $url . '

'; + } + + return implode("\n", $lines); + } +} diff --git a/app/command/InquiryImportFbCommand.php b/app/command/InquiryImportFbCommand.php new file mode 100644 index 0000000..3c9a3c0 --- /dev/null +++ b/app/command/InquiryImportFbCommand.php @@ -0,0 +1,256 @@ +addOption( + 'file', + null, + InputOption::VALUE_OPTIONAL, + '要导入的单个 Excel 文件名(相对 public/excel 的路径,或绝对路径)' + ) + ->addOption( + 'dir', + null, + InputOption::VALUE_OPTIONAL, + 'Excel 目录(相对 public/ 的路径)', + 'excel' // 默认就是 public/excel + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $fileOption = $input->getOption('file'); + $dirOption = $input->getOption('dir') ?? 'excel'; + + // 基准目录:public/{$dirOption} + $baseDir = rtrim(public_path($dirOption), DIRECTORY_SEPARATOR); + + if ($fileOption) { + // 如果传的是绝对路径,直接用;否则认为是相对 baseDir 的文件名 + if ($this->isAbsolutePath($fileOption)) { + $filePath = $fileOption; + } else { + $filePath = $baseDir . DIRECTORY_SEPARATOR . $fileOption; + } + + $output->writeln("开始导入单个文件: {$filePath}"); + + $this->importOneFile($filePath, $output); + } else { + // 不指定 file 时,默认导入目录下所有 .xlsx + if (!is_dir($baseDir)) { + throw new BusinessException("目录不存在:{$baseDir}"); + } + + $pattern = $baseDir . DIRECTORY_SEPARATOR . '*.xlsx'; + $files = glob($pattern); + + if (empty($files)) { + $output->writeln("目录 {$baseDir} 下没有找到 .xlsx 文件"); + return self::SUCCESS; + } + + $output->writeln("开始批量导入目录: {$baseDir}"); + foreach ($files as $filePath) { + $output->writeln(" -> 文件:{$filePath}"); + try { + $this->importOneFile($filePath, $output); + $output->writeln(" 导入成功"); + } catch (\Throwable $e) { + // 某个文件失败,不影响后面的文件 + $output->writeln(" 导入失败:{$e->getMessage()}"); + } + } + } + + return self::SUCCESS; + } + + /** + * 导入单个 Excel 文件 + */ + protected function importOneFile(string $filePath, OutputInterface $output): void + { + if (!is_file($filePath)) { + throw new BusinessException("文件不存在:{$filePath}"); + } + + $spreadsheet = IOFactory::load($filePath); + $sheet = $spreadsheet->getActiveSheet(); + $rows = $sheet->toArray(null, true, true, true); + + if (empty($rows)) { + throw new BusinessException('Excel 内容为空:' . $filePath); + } + + // 取第一行表头 + $headerRow = array_shift($rows); + + // 表头映射器 + $mapper = new FbLeadHeaderMapper(); + + // 如果有未知表头,这里会直接抛异常中断当前文件 + $colToField = $mapper->buildColumnFieldMap($headerRow); + + $insertData = []; + $now = time(); + + foreach ($rows as $row) { + $item = []; + + foreach ($colToField as $colIndex => $field) { + $raw = $row[$colIndex] ?? null; + + switch ($field) { + // 1) is_organic: Excel 里是 true/false,这里转成 0/1 + case 'is_organic': + $item['is_organic'] = $this->normalizeIsOrganic($raw); + break; + + // 2) fb_created_time: 统一转成 Y-m-d H:i:s + case 'fb_created_time': + $item['fb_created_time'] = $this->normalizeFbCreatedTime($raw); + break; + + default: + if (is_string($raw)) { + $raw = trim($raw); + } + $item[$field] = $raw; + break; + } + } + + // 补充公共字段 + $item['source_file'] = basename($filePath); + $item['created_at'] = $now; + $item['updated_at'] = $now; + + // 整行都空就跳过 + $allNull = true; + foreach ($item as $v) { + if ($v !== null && $v !== '') { + $allNull = false; + break; + } + } + if ($allNull) { + continue; + } + + $insertData[] = $item; + } + + if ($insertData) { + Db::table('fb_leads')->insert($insertData); + } else { + $output->writeln(" 文件 {$filePath} 没有有效数据行"); + } + } + + /** + * 将 Excel 里的 is_organic 值转换为 0/1 + */ + protected function normalizeIsOrganic($value): int + { + if ($value === null) { + return 0; + } + + $v = strtolower(trim((string)$value)); + + // 认为这些是 “true” + $trueValues = ['1', 'true', 'yes', 'y']; + + return in_array($v, $trueValues, true) ? 1 : 0; + } + + /** + * 统一处理 fb_created_time + * Excel 目前是类似 '12/8/25',这里尽量转成 'Y-m-d H:i:s' + * 解析失败时返回 null + */ + protected function normalizeFbCreatedTime($value): ?string + { + if ($value === null || $value === '') { + return null; + } + + if ($value instanceof \DateTimeInterface) { + return $value->format('Y-m-d H:i:s'); + } + + $v = trim((string)$value); + + $formats = [ + 'Y-m-d H:i:s', + 'Y-m-d', + 'n/j/y', // 12/8/25 + 'n/j/Y', + 'd/m/Y', + 'd.m.Y', + ]; + + foreach ($formats as $fmt) { + $dt = \DateTime::createFromFormat($fmt, $v); + if ($dt !== false) { + return $dt->format('Y-m-d H:i:s'); + } + } + + // 纯数字当时间戳试一下 + if (is_numeric($v) && strlen($v) >= 9 && strlen($v) <= 11) { + $ts = (int)$v; + if ($ts > 0) { + return date('Y-m-d H:i:s', $ts); + } + } + + return null; + } + + /** + * 判断是否为绝对路径(简单兼容 Windows / Linux) + */ + protected function isAbsolutePath(string $path): bool + { + // Windows: X:\ 或 \\server\share + if (preg_match('/^[a-zA-Z]:\\\\/', $path) || str_starts_with($path, '\\\\')) { + return true; + } + + // *nix: / 开头 + return str_starts_with($path, '/'); + } +} + +/* + +php webman tb-db +# 等价于 php webman tb-db --dir=excel +# 会遍历 public/excel/*.xlsx 逐个导入 + +# 相对路径(相对 public/excel) +php webman tb-db --file="BYD tang_Leads_2025-12-04_2025-12-09.xlsx" + +# 或给绝对路径 +php webman tb-db --file="/www/wwwroot/xxx/public/excel/BYD tang_Leads_2025-12-04_2025-12-09.xlsx" + +*/ \ No newline at end of file diff --git a/app/command/VehMoneyCommand.php b/app/command/VehMoneyCommand.php new file mode 100644 index 0000000..0485e09 --- /dev/null +++ b/app/command/VehMoneyCommand.php @@ -0,0 +1,29 @@ +service = new UsedcarCrawlResultsService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/controller/UsedcarCrawlResultsImagesController.php b/app/common/controller/UsedcarCrawlResultsImagesController.php new file mode 100644 index 0000000..5560fda --- /dev/null +++ b/app/common/controller/UsedcarCrawlResultsImagesController.php @@ -0,0 +1,33 @@ +service = new UsedcarCrawlResultsImagesService(); + } + + /** + * 列表页数据格式化 + * + * @param array $data + * @return array + */ + // protected function indexFormat($data) + // { + // if (isset($data['created_at'])) + // $data['created_at'] = date('Y-m-d H:i:s', $data['created_at']); + // if (isset($data['updated_at'])) + // $data['updated_at'] = date('Y-m-d H:i:s', $data['updated_at']); + // return $data; + // } +} \ No newline at end of file diff --git a/app/common/logic/UsedcarCrawlResultsImagesLogic.php b/app/common/logic/UsedcarCrawlResultsImagesLogic.php new file mode 100644 index 0000000..428bc16 --- /dev/null +++ b/app/common/logic/UsedcarCrawlResultsImagesLogic.php @@ -0,0 +1,17 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/service/UsedcarCrawlResultsService.php b/app/common/service/UsedcarCrawlResultsService.php new file mode 100644 index 0000000..5aa1cdd --- /dev/null +++ b/app/common/service/UsedcarCrawlResultsService.php @@ -0,0 +1,31 @@ +validator; + } +} \ No newline at end of file diff --git a/app/common/utils/crawl/service/UsedCarCrawl.php b/app/common/utils/crawl/service/UsedCarCrawl.php new file mode 100644 index 0000000..7e99090 --- /dev/null +++ b/app/common/utils/crawl/service/UsedCarCrawl.php @@ -0,0 +1,857 @@ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/', + ]; + + // Redis队列键名 + private const REDIS_QUEUE_KEY = 'usedcar:urls'; + + // 能源类型常量 + public const ENERGY_TYPE_RENEWABLE = 'RE'; + public const ENERGY_TYPE_CONVENTIONAL = 'CE'; + public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN'; + + /** + * 插入队列url + * + * @param string $url URL地址 + * @param string $scene 场景:usedcar(二手信息) config(配置信息) + * @param int $id 记录ID + * @return int + */ + public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int + { + $data = [ + 'scene' => $scene, + 'url' => $url, + 'id' => $id, + ]; + + return Redis::lpush( + self::REDIS_QUEUE_KEY, + json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ); + } + + /** + * 获取队列url + * + * @return array|null + * @throws BusinessException + */ + public function popUrl(): ?array + { + $data = Redis::rPop(self::REDIS_QUEUE_KEY); + if (!$data) { + return null; + } + + $task = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($task)) { + throw new BusinessException('任务数据解析失败:' . json_last_error_msg()); + } + + // 基本字段校验 + if (empty($task['url']) || empty($task['scene'])) { + throw new BusinessException('队列任务数据缺少必要字段:url 或 scene'); + } + + return $task; + } + + /** + * 执行抓取任务 + * + * @return bool + * @throws BusinessException + */ + public function execute(): bool + { + try { + $task = $this->popUrl(); + + if (!$task) { + // 队列为空,任务结束 + return false; + } + + $url = (string)$task['url']; + $scene = (string)$task['scene']; + $id = (int)($task['id'] ?? 0); + + $html = $this->fetchHtml($url); + if ($html === '') { + throw new BusinessException("页面内容为空: {$url}"); + } + + switch ($scene) { + case 'usedcar': + $info = $this->parseUsedCarDetail($html, $url); + $insertId = $this->saveUsedCarBasicInfo($info); + + // 下载图片 + 上传 + 入库(图片失败不影响主记录) + $imagesArray = json_decode($info['images'] ?? '[]', true); + if (json_last_error() !== JSON_ERROR_NONE) { + $imagesArray = []; + } + $saveDir = public_path('uploads/usedcar/' . $insertId); + $this->uploadAndRemove($imagesArray, $saveDir, $info['used_car_id'], $insertId); + + // 继续派发配置页任务 + if (!empty($info['car_id_url'])) { + $this->pushUrl($info['car_id_url'], 'config', $insertId); + } + break; + + case 'config': + if ($id <= 0) { + throw new BusinessException("配置任务缺少主记录ID"); + } + + $data = $this->parseConfigDetail($html); + $this->saveUsedCarConfigInfo($data, $id); + break; + + default: + throw new BusinessException("未知的任务场景: " . $scene); + } + + return true; + } catch (GuzzleException $e) { + throw new BusinessException("网络请求失败: " . $e->getMessage()); + } catch (BusinessException $e) { + // 业务异常原样抛出,方便上层知道是逻辑问题 + throw $e; + } catch (Throwable $th) { + // 兜底异常,避免 worker 直接挂掉 + throw new BusinessException( + "执行任务出错: " . $th->getMessage() + . ' | 行号: ' . $th->getLine() + . ' | 文件: ' . $th->getFile() + ); + } + } + + /** + * 获取网页HTML内容 + * + * @param string $url + * @return string + * @throws GuzzleException + */ + public function fetchHtml(string $url): string + { + $client = new Client(); + $response = $client->request('GET', $url, [ + 'headers' => self::DEFAULT_HEADERS, + 'timeout' => 10, + ]); + + return (string)$response->getBody(); + } + + /** + * 解析二手车详情页信息 + * + * @param string $html + * @param string $url + * @return array + * @throws BusinessException + */ + public function parseUsedCarDetail(string $html = '', string $url = ''): array + { + try { + $ql = QueryList::html($html); + } catch (Throwable $e) { + throw new BusinessException('解析 HTML 失败(详情页): ' . $e->getMessage()); + } + + $title = trim($ql->find('h1')->text()); + $price = trim($ql->find('.head-info_price-wrap__Y4bxi > p')->text()); + $pageText = $ql->find('body')->text(); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + if ($jsonText === '') { + throw new BusinessException('详情页未找到 __NEXT_DATA__ JSON'); + } + + $jsonArray = $this->decodeJson($jsonText, '详情页 __NEXT_DATA__'); + + // 车辆相册 + $images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? []; + + // 车辆ID / 年份 + $carInfo = $jsonArray['props']['pageProps']['skuDetail']['car_info'] ?? []; + $carId = $carInfo['car_id'] ?? 0; + $year = (int)($carInfo['year'] ?? 0); + + // 车辆ID URL + $carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : ''; + + // 能源类型(两个字段择一) + $overview = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview'] ?? []; + $fuelForm = $overview['power']['fuel_form'] ?? ''; + $newFuelForm = $overview['new_energy_power']['fuel_form'] ?? ''; + $resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm; + + // 使用里程 + $importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? ''; + $importantTextArray = $importantText ? explode(' | ', $importantText) : []; + $usageMileage = ''; + if (isset($importantTextArray[1])) { + $usageMileage = str_replace('万公里', '', $importantTextArray[1]); + } + + // 车辆名称 品牌、车系、车型 + $brandName = $carInfo['brand_name'] ?? ''; + $seriesName = $carInfo['series_name'] ?? ''; + $carName = $carInfo['car_name'] ?? ''; + + // 商家信息 + $shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? []; + $formatShopInfo = [ + 'shop_id' => $shopInfo['shop_id'] ?? 0, + 'shop_name' => $shopInfo['shop_short_name'] ?? '', + 'shop_address' => $shopInfo['shop_address'] ?? '', + ]; + + // 简介参数 + $otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? []; + $displacement = $this->getParamValue($otherParams, '排量'); + $gearbox = $this->getParamValue($otherParams, '变速箱'); + + $formatCarInfo = [ + 'displacement' => $displacement, + 'gearbox' => $gearbox, + ]; + + // 提取新车指导价 / 比新车省金额 + $newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u'); + $priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u'); + + // 提取车况介绍 + $conditionDesc = ''; + if ($pageText && preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) { + $conditionDesc = trim($m[1]); + } + + return [ + 'used_car_id' => $this->extractIdFromUrl($url), + 'car_id' => $carId, + 'url' => $url, + 'title' => $title, + 'price' => str_replace('万', '', $this->decodePrice($price)), + 'new_price' => str_replace('万', '', $newPrice), + 'price_difference' => str_replace('万', '', $priceDifference), + 'condition_desc' => $conditionDesc, + 'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'car_id_url' => $carIdUrl, + 'used_detail' => $jsonText, + 'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'fuel_form' => $resultFuelForm, + 'classify_energy' => $this->detectEnergyType($resultFuelForm), + 'usage_mileage' => $usageMileage, + 'brand_name' => $brandName, + 'series_name' => $seriesName, + 'car_name' => $carName, + 'year' => $year, + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + ]; + } + + /** + * 解析配置详情页信息 + * + * @param string $html + * @return array + * @throws BusinessException + */ + public function parseConfigDetail(string $html = ''): array + { + try { + $ql = QueryList::html($html); + } catch (Throwable $e) { + throw new BusinessException('解析 HTML 失败(配置页): ' . $e->getMessage()); + } + + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + if ($jsonText === '') { + throw new BusinessException('配置页未找到 __NEXT_DATA__ JSON'); + } + + $raw = $this->decodeJson($jsonText, '配置页 __NEXT_DATA__'); + + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + $valuesMap = $this->buildValuesMap($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + return [ + 'format_car_info' => [ + 'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间 + 'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式 + 'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数 + 'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机 + 'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率 + 'electric_description' => $this->getFieldValue($groups, 'electric_description'), // 电动机 + 'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), // 电池容量 + 'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型 + 'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage'), // 续航里程 + ], + 'groups' => $groups, + ]; + } + + /** + * 从车辆配置中获取能源类型 + * + * @param array $data JSON数据 + * @return string|null 能源类型 + */ + public function getFuelFormByConfig(array $data): ?string + { + if (empty($data[0]['items']) || !is_array($data[0]['items'])) { + return null; + } + + foreach ($data[0]['items'] as $item) { + if (isset($item['key']) && $item['key'] === 'fuel_form') { + return $item['value'] ?? null; + } + } + + return null; + } + + /** + * 从数据中根据字段key获取值 + * + * @param mixed $data 数据 + * @param string $field 字段名 + * @return string|null + */ + public function getFieldValue($data, string $field): ?string + { + if (is_string($data)) { + $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + } + + if (!is_array($data)) { + return null; + } + + foreach ($data as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (($item['key'] ?? null) === $field) { + return $item['value'] ?? null; + } + + if (!empty($item['children']) && is_array($item['children'])) { + foreach ($item['children'] as $child) { + if (($child['key'] ?? null) === $field) { + return $child['value'] ?? null; + } + } + } + } + } + } + + return null; + } + + /** + * 从参数列表中获取指定名称的值 + */ + public function getParamValue(array $params, string $name): ?string + { + foreach ($params as $item) { + if (($item['name'] ?? null) === $name) { + return $item['value'] ?? null; + } + } + return null; + } + + /** + * 判断车辆能源类型 + */ + public function detectEnergyType(string $text): string + { + if (function_exists('mb_internal_encoding')) { + mb_internal_encoding('UTF-8'); + } + + // 明确定义的新能源关键字 + $newEnergyKeywords = [ + '纯电', '纯电动', '电动车', 'BEV', + '插电混动', '插电式混合', '插电式', '插电', 'PHEV', + '增程式', '增程', 'REEV', + '氢能', '氢燃料', '燃料电池', 'FCEV', + '太阳能', + ]; + + // 传统能源关键字 + $traditionalKeywords = [ + '汽油', + '柴油', + '天然气', 'LNG', 'CNG', + ]; + + // 普通混动关键字(归为传统能源) + $hybridKeywords = [ + '油电混合', '油电混动', '油电', + '混动', + '双擎', + 'HEV', + ]; + + $contains = function (string $haystack, string $needle): bool { + if ($needle === '') { + return false; + } + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + }; + + foreach ($newEnergyKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_RENEWABLE; + } + } + + foreach ($hybridKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + foreach ($traditionalKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + return self::ENERGY_TYPE_UNKNOWN; + } + + /** + * 保存二手车基本信息到数据库 + * + * @param array $data 数据 + * @return int 插入记录ID + * @throws BusinessException + */ + public function saveUsedCarBasicInfo(array $data): int + { + try { + $time = time(); + $data['created_at'] = $data['created_at'] ?? $time; + $data['updated_at'] = $time; + + return (int)Db::table('usedcar_crawl_results')->insertGetId($data); + } catch (Throwable $e) { + throw new BusinessException('保存二手车基础信息失败:' . $e->getMessage()); + } + } + + /** + * 更新配置信息到数据库 + * + * @param array $data 配置数据 + * @param int $id 记录ID + * @return bool|int + * @throws BusinessException + */ + public function saveUsedCarConfigInfo(array $data, int $id) + { + try { + $record = Db::table('usedcar_crawl_results')->find($id); + + if (!$record) { + // 找不到记录就算了,不抛致命错误,视业务情况也可以抛异常 + return false; + } + + $groups = $data['groups'] ?? []; + $oldFormat = []; + + if (!empty($record->format_car_info)) { + $decoded = json_decode($record->format_car_info, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $oldFormat = $decoded; + } + } + + $formatCarInfo = array_merge( + $oldFormat, + $data['format_car_info'] ?? [] + ); + + return Db::table('usedcar_crawl_results') + ->where('id', $id) + ->update([ + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => time(), + ]); + } catch (Throwable $e) { + throw new BusinessException('保存车辆配置信息失败:' . $e->getMessage()); + } + } + + /** + * 构建值映射表 + */ + public function buildValuesMap(array $carInfo): array + { + $valuesMap = []; + + // 顶层字段 + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // info中的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 构建车辆规格分组 + */ + public function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // 分类字段:type = 0 / 2 + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // 普通字段:挂到最近的分类下 + if ($currentKey === null) { + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + return array_values($groups); + } + + /** + * 解码懂车帝价格 + */ + protected function decodePrice(string $str): string + { + $map = [ + "\u{E53D}" => '1', + "\u{E3F0}" => '2', + "\u{E422}" => '3', + "\u{E42C}" => '4', + "\u{E49C}" => '5', + "\u{E42B}" => '6', + "\u{E4FE}" => '7', + "\u{E548}" => '8', + "\u{E4C8}" => '9', + "\u{E453}" => '0', + "\u{E45F}" => '万', + ]; + + return strtr($str, $map); + } + + /** + * 从URL中提取ID + */ + protected function extractIdFromUrl(string $url) + { + if (preg_match('/\/(\d+)$/', $url, $matches)) { + return (int)$matches[1]; + } + return false; + } + + /** + * 提取符合正则表达式的值(只保留数字和小数点) + */ + protected function extractPatternValue(string $text, string $pattern): string + { + $value = ''; + if ($text && preg_match($pattern, $text, $m)) { + $value = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + return $value; + } + + /** + * 下载单张图片 + */ + public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string + { + if (!is_dir($saveDir)) { + if (!@mkdir($saveDir, 0777, true) && !is_dir($saveDir)) { + // echo "[Image] 创建目录失败:{$saveDir}\n"; + return null; + } + } + + if ($filename === null) { + $path = parse_url($url, PHP_URL_PATH); + $ext = pathinfo($path ?? '', PATHINFO_EXTENSION); + + if ($ext === '') { + $ext = 'jpg'; + } + + $filename = md5($url) . '.' . $ext; + } + + $savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + + $defaultHeaders = [ + 'User-Agent: ' . self::DEFAULT_HEADERS['User-Agent'], + 'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer: ' . self::DEFAULT_HEADERS['Referer'], + ]; + + foreach ($headers as $h) { + $defaultHeaders[] = $h; + } + + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + $data = curl_exec($ch); + $errNo = curl_errno($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errNo !== 0 || $code !== 200 || $data === false || $data === '') { + return null; + } + + $bytes = @file_put_contents($savePath, $data); + if ($bytes === false) { + return null; + } + + return $savePath; + } + + /** + * 批量下载相册 + * + * @param array $urls 图片URL数组 + * @param string $saveDir 保存目录 + * @return array [原始URL => 保存路径或null] + */ + public function downloadImageAlbum(array $urls, string $saveDir): array + { + $result = []; + + foreach ($urls as $url) { + $url = trim((string)$url); + if ($url === '') { + continue; + } + + if (strpos($url, '//') === 0) { + $url = 'https:' . $url; + } + + try { + $path = $this->downloadImage($url, $saveDir); + } catch (Throwable $e) { + $path = null; + } + + $result[$url] = $path; + } + + return $result; + } + + /** + * 下载 -> 上传 -> 删除本地 + * + * @param array $images 原始相册URL列表 + * @param string $path 本地保存目录 + * @param int $usedCarId 懂车帝 used_car_id + * @param int $id 本地 crawl 记录id + * @return bool + */ + public function uploadAndRemove(array $images, string $path, $usedCarId, $id): bool + { + if (empty($images)) { + return false; + } + + // 下载图片 + $filePatch = $this->downloadImageAlbum($images, $path); + if (empty($filePatch)) { + return false; + } + + $uploadService = new UploadService(); + + foreach ($filePatch as $url => $filePath) { + if ($filePath === null) { + continue; + } + + try { + // 上传云端 + $param = [ + 'path' => $filePath, + 'scene' => 'usedcar_crawl', + 'category' => 'usedcar', + ]; + + $result = $uploadService->uploadLocalFile($param); + if (empty($result['file_name'])) { + // 上传失败则跳过,但不抛异常影响主流程 + continue; + } + + // 保存图片信息 + UsedcarCrawlResultsImages::create([ + 'uc_id' => $id, + 'used_car_id' => $usedCarId, + 'original_name'=> $result['original_name'] ?? '', + 'file_path' => $result['file_name'], + ]); + } catch (Throwable $e) { + // 单张图失败不影响其他 + // 可以按需写日志 + } finally { + // 删除本地文件 + if (is_file($filePath)) { + @unlink($filePath); + } + } + } + + // 删除目录 + rmdir($path); + + return true; + } + + /** + * 安全解 JSON + * + * @param string $json + * @param string $context + * @return array + * @throws BusinessException + */ + protected function decodeJson(string $json, string $context = 'JSON'): array + { + $data = json_decode($json, true); + if (json_last_error() !== JSON_ERROR_NONE || !is_array($data)) { + throw new BusinessException($context . ' 解析失败:' . json_last_error_msg()); + } + return $data; + } +} \ No newline at end of file diff --git a/app/common/utils/crawl/service/UsedCarCrawl_bak.php b/app/common/utils/crawl/service/UsedCarCrawl_bak.php new file mode 100644 index 0000000..11ea8c8 --- /dev/null +++ b/app/common/utils/crawl/service/UsedCarCrawl_bak.php @@ -0,0 +1,681 @@ + $scene, + 'url' => $url, + 'id' => $id + ], JSON_UNESCAPED_UNICODE)); + } + + /** + * 获取队列url + * + * $scene 二手信息 usedcar 配置信息 config + */ + public function rUrl() + { + return json_decode(Redis::rPop('usedcar:urls'), true); + } + + /** + * 执行抓取 + */ + public function doData() + { + try { + $raw = $this->rUrl(); + + if (!$raw) { + return false; + } + + $html = $this->getHtml($raw['url']); + + switch ($raw['scene']) { + case 'usedcar': + $info = $this->getUsedDetail($html, $raw['url']); + $id = $this->saveUsedcarBasicToDb($info); + $this->iUrl($info['car_id_url'], 'config', $id); + break; + + case 'config': + $data = $this->getConfigDetail($html); + $this->saveUsedcarConfigToDb($data, $raw['id']); + break; + } + + return true; + } catch (\Throwable $th) { + throw new BusinessException($th->getMessage().'|'.$th->getLine().'|'.$th->getFile()); + } + } + + /** + * 获取html + */ + public function getHtml($url) + { + $client = new Client(); + $res = $client->request('GET', $url, [ + 'headers' => [ + 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + . 'AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/' + ] + ]); + return (string)$res->getBody(); + } + + /** + * 获取二手信息 + */ + public function getUsedDetail(string $html = '', string $url = '') + { + $ql = QueryList::html($html); + + $title = $ql->find('h1')->text(); + $price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text(); + $pageText = $ql->find('body')->text(); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + // 新车指导价 + $newPrice = ''; + if ($pageText) { + if (preg_match('/新车指导价[::]\s*([^\r\n万]*万)/u', $pageText, $m)) { + $newPrice = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + } + + // 比新车省 + $priceDifference = ''; + if ($pageText) { + if (preg_match('/比新车省[::]\s*([^\r\n万]*万)/u', $pageText, $m)) { + $priceDifference = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + } + + // 车况介绍 + $conditionDesc = ''; + if ($pageText) { + if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) { + $conditionDesc = trim($m[1]); + } + } + + $jsonArray = json_decode($jsonText, true); + + // 车辆相册 + $images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? []; + + // 车辆ID + $carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0; + + // 车辆年份 + $year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0; + + // 车辆ID URL + $carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : ''; + + // 能源类型 + $fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? ''; + $newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? ''; + $resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm; + + // 使用里程 + $importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? ''; + $importantTextArray = explode(' | ', $importantText); + $usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : ''); + + // 车辆名称 品牌、车系、车型 + $brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? ''; + $seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? ''; + $carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? ''; + + // 商家信息 + $shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? []; + $formatShopInfo = [ + 'shop_id' => $shopInfo['shop_id'] ?? 0, + 'shop_name' => $shopInfo['shop_short_name'] ?? '', + 'shop_address' => $shopInfo['shop_address'] ?? '' + ]; + + // 简介参数 + $otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? []; + // 排量 + $displacement = $this->getOtherParamValue($otherParams, '排量'); + // 变速箱 + $gearbox = $this->getOtherParamValue($otherParams, '变速箱'); + // 格式化车辆信息 + $formatCarInfo = [ + 'displacement' => $displacement, + 'gearbox' => $gearbox + ]; + + // 整理车辆信息 + $info['used_car_id'] = $this->getLastIdFromUrl($url); + $info['car_id'] = $carId; + $info['url'] = $url; + $info['title'] = $title; + $info['price'] = str_replace('万', '', $this->decodeDongchediPrice($price)); + $info['new_price'] = str_replace('万', '', $newPrice); + $info['price_difference'] = str_replace('万', '', $priceDifference); + $info['condition_desc'] = $conditionDesc; + $info['images'] = json_encode($images); + $info['car_id_url'] = $carIdUrl; + $info['used_detail'] = $jsonText; + $info['format_shop_info'] = json_encode($formatShopInfo); + $info['fuel_form'] = $resultFuelForm; + $info['classify_energy'] = $this->detectEnergyType($resultFuelForm); + $info['usage_mileage'] = $usageMileage; + $info['brand_name'] = $brandName; + $info['series_name'] = $seriesName; + $info['car_name'] = $carName; + $info['year'] = $year; + $info['format_car_info'] = json_encode($formatCarInfo); + + return $info; + } + + /** + * 获取配置信息 + */ + public function getConfigDetail(string $html = '') + { + $ql = QueryList::html($html); + + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + $raw = json_decode($jsonText, true); + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + $valuesMap = $this->buildValuesMapFromCarInfo($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + $data['format_car_info'] = [ + 'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间 + 'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式 + 'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数 + 'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机 + 'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率 + 'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机 + 'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量 + 'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型 + 'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程 + ]; + $data['groups'] = $groups; + return $data; + } + + /** + * 从车辆配置 JSON 数据中获取能源类型 + * + * @param array $data JSON 数据 + * @return string|null 能源类型,如 '油电混合'、'纯电动'、 + */ + public function getFuelFormByConfigInfo(array $data) + { + $fuelForm = null; + foreach ($data[0]['items'] as $item) { + if ($item['key'] === 'fuel_form') { + $fuelForm = $item['value']; + break; + } + } + return $fuelForm; + } + + /** + * 从车辆配置 JSON 数据中根据字段 key 获取对应的 value + * + * @param string|array $data JSON 字符串 或 已解析的数组 + * @param string $field 要查找的字段 key(如 'fuel_form') + * @return string|null 找到则返回 value,否则返回 null + */ + public function getFieldValue($data, string $field) + { + // 如果传入的是 JSON 字符串,先解码 + if (is_string($data)) { + $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; // JSON 格式错误 + } + } + + // 遍历第一层(假设结构是 [ { "items": [...] } ]) + foreach ($data as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (isset($item['key']) && $item['key'] === $field) { + return $item['value'] ?? null; + } + } + } + } + + return null; // 未找到 + } + + /** + * 从 other_params 数组中根据 name 获取对应的 value + * + * @param array $otherParams 格式为 [ ['name' => 'xxx', 'value' => 'yyy'], ... ] + * @param string $name 要查找的字段名称(如 '上牌地') + * @return string|null 找到则返回 value,否则返回 null + */ + public function getOtherParamValue(array $otherParams, string $name): ?string + { + foreach ($otherParams as $item) { + if (isset($item['name']) && $item['name'] === $name) { + return $item['value'] ?? null; + } + } + return null; + } + + /** + * 根据给定字符串判断车辆能源类型 + * + * 返回: + * - RE 新能源(纯电、插电混动、增程、氢燃料等) + * - CE 传统能源(汽油、柴油、天然气、非插电混动等) + * - UNKNOWN 无法判断 + * + * @param string $text 例如:'2022款 某某牌 2.0L 油电混合 双擎' + * @return string + */ + public function detectEnergyType(string $text): string + { + if (function_exists('mb_internal_encoding')) { + mb_internal_encoding('UTF-8'); + } + + // 明确定义的新能源关键字(纯电 / 插电 / 增程 / 氢 / 太阳能 等) + $newEnergyKeywords = [ + // 纯电 + '纯电', '纯电动', '电动车', 'BEV', + // 插电式混合 + '插电混动', '插电式混合', '插电式', '插电', 'PHEV', + // 增程式 + '增程式', '增程', 'REEV', + // 氢能 / 燃料电池 + '氢能', '氢燃料', '燃料电池', 'FCEV', + // 其它 + '太阳能', + ]; + + // 传统能源关键字(包括燃油/燃气) + $traditionalKeywords = [ + '汽油', + '柴油', + '天然气', 'LNG', 'CNG', + ]; + + // 普通混动关键字(按你的要求,统一归为传统能源) + $hybridKeywords = [ + '油电混合', '油电混动', '油电', + '混动', // 不带“插电”的混动 + '双擎', // 丰田常见表达 + 'HEV', + ]; + + // 辅助:不区分大小写的包含判断,优先 mb_stripos + $contains = function (string $haystack, string $needle): bool { + if ($needle === '') { + return false; + } + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + }; + + // 1. 先判断“明确新能源”关键词(插电、增程、氢、纯电等) + foreach ($newEnergyKeywords as $kw) { + if ($contains($text, $kw)) { + return 'RE'; + } + } + + // 2. 再判断“普通油电混动”——按照你的业务,一律归为传统能源 + foreach ($hybridKeywords as $kw) { + if ($contains($text, $kw)) { + return 'CE'; + } + } + + // 3. 再判断传统能源(纯燃油/气) + foreach ($traditionalKeywords as $kw) { + if ($contains($text, $kw)) { + return 'CE'; + } + } + + // 4. 都没匹配上 + return 'UNKNOWN'; + } + + /** + * 二手信息入库 + */ + public function saveUsedcarBasicToDb(array $data = []) + { + $data['created_at'] = time(); + $data['updated_at'] = time(); + return Db::table('usedcar_crawl_results')->insertGetId($data); + } + + /** + * 更新信息入库 + */ + public function saveUsedcarConfigToDb(array $data = [], int $id = 0) + { + $info = Db::table('usedcar_crawl_results')->find($id); + + if (!$info) { + return false; + } + + $groups = $data['groups']; + $formatCarInfo = array_merge($data['format_car_info'], json_decode($info->format_car_info, true)); + + return Db::table('usedcar_crawl_results') + ->where('id', $id) + ->update([ + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE), + 'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE), + 'updated_at' => time() + ]); + } + + /** + * 把 car_info 扁平成一个 [key => ['value' => xxx]] 的 map + * + * @param array $carInfo rawData['car_info'][0] + * @return array + */ + public function buildValuesMapFromCarInfo(array $carInfo): array + { + $valuesMap = []; + + // 1) car_info 顶层的一些字段(official_price、series_name 等) + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // 2) info 里的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 根据 type 重新分组: + * - type=0,2 为分组(左侧分类) + * - 其它 type 都视为该分组下的字段 + * + * @param array $properties rawData['properties'] + * @param array $valuesMap 来自 buildValuesMapFromCarInfo() + * @return array + */ + public function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; // 当前分组 key + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // ---------- 1) 分类字段:type = 0 / 2 ---------- + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // ---------- 2) 普通字段:挂到最近的分类下 ---------- + if ($currentKey === null) { + // 如果一开始就没有遇到分类,给一个默认分组 + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + // 取 value + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + // ---------- 3) sub_list 子项(有就当成 children) ---------- + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + // 你可以按需返回关联数组或索引数组,这里转成索引数组方便前端遍历 + return array_values($groups); + } + + /** + * 转义价格 + */ + protected function decodeDongchediPrice(string $str): string + { + // 映射表:PUA 字符 => 数字 / 万 + $map = [ + "\u{E53D}" => '1', + "\u{E3F0}" => '2', + "\u{E422}" => '3', + "\u{E42C}" => '4', + "\u{E49C}" => '5', + "\u{E42B}" => '6', + "\u{E4FE}" => '7', + "\u{E548}" => '8', + "\u{E4C8}" => '9', + "\u{E453}" => '0', + "\u{E45F}" => '万', + ]; + + return strtr($str, $map); + } + + /** + * 获取URL最后ID + */ + protected function getLastIdFromUrl($url) + { + // 方法1:使用正则表达式提取最后的数字ID + if (preg_match('/\/(\d+)$/', $url, $matches)) { + return $matches[1]; + } + return false; + } + + /** + * 下载单张图片 + * + * @param string $url 图片完整 URL + * @param string $saveDir 保存目录(如 /www/usedcar_images/2025-11-20) + * @param string|null $filename 指定文件名(可选,不含路径),为空则自动生成 + * @param array $headers 额外 HTTP 头(可选) + * + * @return string|null 成功返回保存路径,失败返回 null + */ + protected function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string + { + // 1. 目录检查 & 创建 + if (!is_dir($saveDir)) { + if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) { + echo "[Image] 创建目录失败:{$saveDir}\n"; + return null; + } + } + + // 2. 自动生成文件名(基于 URL md5 + 原扩展名) + if ($filename === null) { + $path = parse_url($url, PHP_URL_PATH); // /xxx/xxx.jpg + $ext = pathinfo($path, PATHINFO_EXTENSION); // jpg 或 webp 之类 + + if ($ext === '') { + $ext = 'jpg'; // 没有扩展名时兜底 + } + + $filename = md5($url) . '.' . $ext; + } + + $savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + + // 3. 组装 HTTP 头 + $defaultHeaders = [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' + . 'AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/120.0.0.0 Safari/537.36', + 'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer: https://www.dongchedi.com/', // 很多图床需要 Referer + ]; + + foreach ($headers as $h) { + $defaultHeaders[] = $h; + } + + // 4. 使用 cURL 下载 + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + $data = curl_exec($ch); + $errNo = curl_errno($ch); + $errMsg = curl_error($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errNo !== 0) { + echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n"; + return null; + } + + if ($code !== 200 || $data === false || $data === '') { + echo "[Image] 下载失败(HTTP {$code}):{$url}\n"; + return null; + } + + // 5. 写入本地文件 + $bytes = file_put_contents($savePath, $data); + if ($bytes === false) { + echo "[Image] 保存失败:{$savePath}\n"; + return null; + } + + echo "[Image] 下载成功:{$url} => {$savePath}\n"; + + return $savePath; + } + + /** + * 批量下载相册 + * + * @param array $urls 图片 URL 数组 + * @param string $saveDir 保存目录 + * + * @return array [原始 URL => 保存路径或 null] + */ + protected function downloadImageAlbum(array $urls, string $saveDir): array + { + $result = []; + + foreach ($urls as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + + // 如果存在 // 开头的相对协议,补全 + if (strpos($url, '//') === 0) { + $url = 'https:' . $url; + } + + $path = $this->downloadImage($url, $saveDir); + $result[$url] = $path; + } + + return $result; + } +} diff --git a/app/common/utils/crawl/service/UsedCarCrawl_bak2.php b/app/common/utils/crawl/service/UsedCarCrawl_bak2.php new file mode 100644 index 0000000..2612baa --- /dev/null +++ b/app/common/utils/crawl/service/UsedCarCrawl_bak2.php @@ -0,0 +1,798 @@ + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept-Language' => 'zh-CN,zh;q=0.9', + 'Referer' => 'https://www.dongchedi.com/' + ]; + + // Redis队列键名 + private const REDIS_QUEUE_KEY = 'usedcar:urls'; + + // 能源类型常量 + public const ENERGY_TYPE_RENEWABLE = 'RE'; + public const ENERGY_TYPE_CONVENTIONAL = 'CE'; + public const ENERGY_TYPE_UNKNOWN = 'UNKNOWN'; + + /** + * 插入队列url + * + * @param string $url URL地址 + * @param string $scene 场景:usedcar(二手信息) config(配置信息) + * @param int $id 记录ID + * @return int + */ + public function pushUrl(string $url = '', string $scene = 'usedcar', int $id = 0): int + { + $data = [ + 'scene' => $scene, + 'url' => $url, + 'id' => $id + ]; + + return Redis::lpush(self::REDIS_QUEUE_KEY, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + } + + /** + * 获取队列url + * + * @return array|null + */ + public function popUrl(): ?array + { + $data = Redis::rPop(self::REDIS_QUEUE_KEY); + if (!$data) { + return null; + } + + return json_decode($data, true); + } + + /** + * 执行抓取任务 + * + * @return bool + * @throws BusinessException + */ + public function execute(): bool + { + try { + $task = $this->popUrl(); + + if (!$task) { + return false; + } + + $html = $this->fetchHtml($task['url']); + + switch ($task['scene']) { + case 'usedcar': + $info = $this->parseUsedCarDetail($html, $task['url']); + $id = $this->saveUsedCarBasicInfo($info); + $this->uploadAndRemove(json_decode($info['images'], true), public_path('uploads/usedcar/' . $id), $info['used_car_id'], $id); + $this->pushUrl($info['car_id_url'], 'config', $id); + break; + + case 'config': + $data = $this->parseConfigDetail($html); + $this->saveUsedCarConfigInfo($data, $task['id']); + break; + + default: + throw new BusinessException("未知的任务场景: " . $task['scene']); + } + + return true; + } catch (GuzzleException $e) { + throw new BusinessException("网络请求失败: " . $e->getMessage()); + } catch (Throwable $th) { + throw new BusinessException("执行任务出错: " . $th->getMessage() . ' | 行号: ' . $th->getLine() . ' | 文件: ' . $th->getFile()); + } + } + + /** + * 获取网页HTML内容 + * + * @param string $url + * @return string + * @throws GuzzleException + */ + public function fetchHtml(string $url): string + { + $client = new Client(); + $response = $client->request('GET', $url, [ + 'headers' => self::DEFAULT_HEADERS + ]); + return (string)$response->getBody(); + } + + /** + * 解析二手车详情页信息 + * + * @param string $html + * @param string $url + * @return array + */ + public function parseUsedCarDetail(string $html = '', string $url = ''): array + { + $ql = QueryList::html($html); + + $title = $ql->find('h1')->text(); + $price = $ql->find('.head-info_price-wrap__Y4bxi > p')->text(); + $pageText = $ql->find('body')->text(); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + // 解析JSON数据 + $jsonArray = json_decode($jsonText, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new BusinessException("JSON解析失败: " . json_last_error_msg()); + } + + // 车辆相册 + $images = $jsonArray['props']['pageProps']['skuDetail']['head_images'] ?? []; + + // 车辆ID + $carId = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_id'] ?? 0; + + // 车辆年份 + $year = $jsonArray['props']['pageProps']['skuDetail']['car_info']['year'] ?? 0; + + // 车辆ID URL + $carIdUrl = $carId ? ('https://www.dongchedi.com/auto/params-carIds-' . $carId) : ''; + + // 能源类型 + $fuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['power']['fuel_form'] ?? ''; + $newFuelForm = $jsonArray['props']['pageProps']['skuDetail']['car_config_overview']['new_energy_power']['fuel_form'] ?? ''; + $resultFuelForm = $fuelForm === '' ? $newFuelForm : $fuelForm; + + // 使用里程 + $importantText = $jsonArray['props']['pageProps']['skuDetail']['important_text'] ?? ''; + $importantTextArray = explode(' | ', $importantText); + $usageMileage = str_replace('万公里', '', isset($importantTextArray[1]) ? $importantTextArray[1] : ''); + + // 车辆名称 品牌、车系、车型 + $brandName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['brand_name'] ?? ''; + $seriesName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['series_name'] ?? ''; + $carName = $jsonArray['props']['pageProps']['skuDetail']['car_info']['car_name'] ?? ''; + + // 商家信息 + $shopInfo = $jsonArray['props']['pageProps']['skuDetail']['shop_info'] ?? []; + $formatShopInfo = [ + 'shop_id' => $shopInfo['shop_id'] ?? 0, + 'shop_name' => $shopInfo['shop_short_name'] ?? '', + 'shop_address' => $shopInfo['shop_address'] ?? '' + ]; + + // 简介参数 + $otherParams = $jsonArray['props']['pageProps']['skuDetail']['other_params'] ?? []; + // 排量 + $displacement = $this->getParamValue($otherParams, '排量'); + // 变速箱 + $gearbox = $this->getParamValue($otherParams, '变速箱'); + // 格式化车辆信息 + $formatCarInfo = [ + 'displacement' => $displacement, + 'gearbox' => $gearbox + ]; + + // 提取新车指导价 + $newPrice = $this->extractPatternValue($pageText, '/新车指导价[::]\s*([^\r\n万]*万)/u'); + + // 提取比新车省金额 + $priceDifference = $this->extractPatternValue($pageText, '/比新车省[::]\s*([^\r\n万]*万)/u'); + + // 提取车况介绍 + $conditionDesc = ''; + if ($pageText) { + if (preg_match('/车况介绍\s*(.*?)\s*推荐理由/su', $pageText, $m)) { + $conditionDesc = trim($m[1]); + } + } + + // 整理车辆信息 + return [ + 'used_car_id' => $this->extractIdFromUrl($url), + 'car_id' => $carId, + 'url' => $url, + 'title' => $title, + 'price' => str_replace('万', '', $this->decodePrice($price)), + 'new_price' => str_replace('万', '', $newPrice), + 'price_difference' => str_replace('万', '', $priceDifference), + 'condition_desc' => $conditionDesc, + 'images' => json_encode($images, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'car_id_url' => $carIdUrl, + 'used_detail' => $jsonText, + 'format_shop_info' => json_encode($formatShopInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'fuel_form' => $resultFuelForm, + 'classify_energy' => $this->detectEnergyType($resultFuelForm), + 'usage_mileage' => $usageMileage, + 'brand_name' => $brandName, + 'series_name' => $seriesName, + 'car_name' => $carName, + 'year' => $year, + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ]; + } + + /** + * 解析配置详情页信息 + * + * @param string $html + * @return array + */ + public function parseConfigDetail(string $html = ''): array + { + $ql = QueryList::html($html); + $jsonText = $ql->find('#__NEXT_DATA__')->text(); + + $raw = json_decode($jsonText, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new BusinessException("配置页面JSON解析失败: " . json_last_error_msg()); + } + + $properties = $raw['props']['pageProps']['rawData']['properties'] ?? []; + $carInfo = $raw['props']['pageProps']['rawData']['car_info'][0] ?? []; + + $valuesMap = $this->buildValuesMap($carInfo); + $groups = $this->buildCarSpecGroups($properties, $valuesMap); + + return [ + 'format_car_info' => [ + 'market_time' => $this->getFieldValue($groups, 'market_time'), // 上市时间 + 'driver_form' => $this->getFieldValue($groups, 'driver_form'), // 驱动形式 + 'seat_count' => $this->getFieldValue($groups, 'seat_count'), // 座位数 + 'engine_description' => $this->getFieldValue($groups, 'engine_description'), // 发动机 + 'engine_max_power_horsepower' => $this->getFieldValue($groups, 'engine_max_power_horsepower'), // 发动机最大功率 + 'electric_description' => $this->getFieldValue($groups, 'electric_description'), //电动机 + 'battery_capacity' => $this->getFieldValue($groups, 'battery_capacity'), //电池容量 + 'battery_type' => $this->getFieldValue($groups, 'battery_type'), // 电池类型 + 'recharge_mileage' => $this->getFieldValue($groups, 'recharge_mileage') // 续航里程 + ], + 'groups' => $groups + ]; + } + + /** + * 从车辆配置中获取能源类型 + * + * @param array $data JSON数据 + * @return string|null 能源类型 + */ + public function getFuelFormByConfig(array $data): ?string + { + if (empty($data[0]['items'])) { + return null; + } + + foreach ($data[0]['items'] as $item) { + if (isset($item['key']) && $item['key'] === 'fuel_form') { + return $item['value'] ?? null; + } + } + + return null; + } + + /** + * 从数据中根据字段key获取值 + * + * @param mixed $data 数据 + * @param string $field 字段名 + * @return string|null + */ + public function getFieldValue($data, string $field): ?string + { + // 如果传入的是 JSON 字符串,先解码 + if (is_string($data)) { + $data = json_decode($data, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + } + + // 如果不是数组,直接返回null + if (!is_array($data)) { + return null; + } + + // 遍历寻找对应字段 + foreach ($data as $group) { + if (isset($group['items']) && is_array($group['items'])) { + foreach ($group['items'] as $item) { + if (isset($item['key']) && $item['key'] === $field) { + return $item['value'] ?? null; + } + + // 检查子元素 + if (isset($item['children']) && is_array($item['children'])) { + foreach ($item['children'] as $child) { + if (isset($child['key']) && $child['key'] === $field) { + return $child['value'] ?? null; + } + } + } + } + } + } + + return null; + } + + /** + * 从参数列表中获取指定名称的值 + * + * @param array $params 参数列表 + * @param string $name 名称 + * @return string|null + */ + public function getParamValue(array $params, string $name): ?string + { + foreach ($params as $item) { + if (isset($item['name']) && $item['name'] === $name) { + return $item['value'] ?? null; + } + } + return null; + } + + /** + * 判断车辆能源类型 + * + * @param string $text 描述文本 + * @return string 能源类型标识 + */ + public function detectEnergyType(string $text): string + { + if (function_exists('mb_internal_encoding')) { + mb_internal_encoding('UTF-8'); + } + + // 明确定义的新能源关键字 + $newEnergyKeywords = [ + // 纯电 + '纯电', '纯电动', '电动车', 'BEV', + // 插电式混合 + '插电混动', '插电式混合', '插电式', '插电', 'PHEV', + // 增程式 + '增程式', '增程', 'REEV', + // 氢能 / 燃料电池 + '氢能', '氢燃料', '燃料电池', 'FCEV', + // 其它 + '太阳能', + ]; + + // 传统能源关键字 + $traditionalKeywords = [ + '汽油', + '柴油', + '天然气', 'LNG', 'CNG', + ]; + + // 普通混动关键字(归为传统能源) + $hybridKeywords = [ + '油电混合', '油电混动', '油电', + '混动', // 不带"插电"的混动 + '双擎', // 丰田常见表达 + 'HEV', + ]; + + // 辅助函数:不区分大小写的包含判断 + $contains = function (string $haystack, string $needle): bool { + if ($needle === '') { + return false; + } + if (function_exists('mb_stripos')) { + return mb_stripos($haystack, $needle) !== false; + } + return stripos($haystack, $needle) !== false; + }; + + // 1. 先判断"明确新能源"关键词 + foreach ($newEnergyKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_RENEWABLE; + } + } + + // 2. 再判断"普通油电混动" + foreach ($hybridKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + // 3. 再判断传统能源 + foreach ($traditionalKeywords as $kw) { + if ($contains($text, $kw)) { + return self::ENERGY_TYPE_CONVENTIONAL; + } + } + + // 4. 都没匹配上 + return self::ENERGY_TYPE_UNKNOWN; + } + + /** + * 保存二手车基本信息到数据库 + * + * @param array $data 数据 + * @return int 插入记录ID + */ + public function saveUsedCarBasicInfo(array $data): int + { + $data['created_at'] = time(); + $data['updated_at'] = time(); + return Db::table('usedcar_crawl_results')->insertGetId($data); + } + + /** + * 更新配置信息到数据库 + * + * @param array $data 配置数据 + * @param int $id 记录ID + * @return bool|int + */ + public function saveUsedCarConfigInfo(array $data, int $id) + { + $record = Db::table('usedcar_crawl_results')->find($id); + + if (!$record) { + return false; + } + + $groups = $data['groups']; + $formatCarInfo = array_merge( + $data['format_car_info'], + json_decode($record->format_car_info, true) + ); + + return Db::table('usedcar_crawl_results') + ->where('id', $id) + ->update([ + 'format_car_info' => json_encode($formatCarInfo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'config_detail' => json_encode($groups, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 'updated_at' => time() + ]); + } + + /** + * 构建值映射表 + * + * @param array $carInfo 车辆信息 + * @return array + */ + public function buildValuesMap(array $carInfo): array + { + $valuesMap = []; + + // 处理顶层字段 + foreach ($carInfo as $k => $v) { + if ($k === 'info' || is_array($v)) { + continue; + } + $valuesMap[$k] = ['value' => $v]; + } + + // 处理info中的配置项 + if (!empty($carInfo['info']) && is_array($carInfo['info'])) { + foreach ($carInfo['info'] as $k => $v) { + if (is_array($v) && array_key_exists('value', $v)) { + $valuesMap[$k] = $v; + } + } + } + + return $valuesMap; + } + + /** + * 构建车辆规格分组 + * + * @param array $properties 属性列表 + * @param array $valuesMap 值映射表 + * @return array + */ + public function buildCarSpecGroups(array $properties, array $valuesMap): array + { + $groups = []; + $currentKey = null; + + foreach ($properties as $item) { + $type = (int)($item['type'] ?? 1); + $key = $item['key'] ?? ''; + $text = $item['text'] ?? ''; + $subList = $item['sub_list'] ?? null; + + // 分类字段:type = 0 / 2 + if (in_array($type, [0, 2], true)) { + $currentKey = $key ?: ($text ?: ('group_' . count($groups))); + + if (!isset($groups[$currentKey])) { + $groups[$currentKey] = [ + 'group_key' => $currentKey, + 'group_text' => $text, + 'group_type' => $type, + 'items' => [], + ]; + } + + continue; + } + + // 普通字段:挂到最近的分类下 + if ($currentKey === null) { + // 如果一开始就没有遇到分类,给一个默认分组 + $currentKey = '_default'; + $groups[$currentKey] = [ + 'group_key' => '_default', + 'group_text' => '其他', + 'group_type' => -1, + 'items' => [], + ]; + } + + // 取 value + $value = ''; + if ($key && isset($valuesMap[$key])) { + $value = trim((string)($valuesMap[$key]['value'] ?? '')); + } + + $node = [ + 'key' => $key, + 'text' => $text, + 'value' => $value, + ]; + + // 处理子项 + if (is_array($subList) && !empty($subList)) { + $children = []; + foreach ($subList as $sub) { + $subKey = $sub['key'] ?? ''; + $subText = $sub['text'] ?? ''; + $subValue = ''; + + if ($subKey && isset($valuesMap[$subKey])) { + $subValue = trim((string)($valuesMap[$subKey]['value'] ?? '')); + } + + $children[] = [ + 'key' => $subKey, + 'text' => $subText, + 'value' => $subValue, + ]; + } + + $node['children'] = $children; + } + + $groups[$currentKey]['items'][] = $node; + } + + // 转成索引数组方便前端遍历 + return array_values($groups); + } + + /** + * 解码懂车帝价格 + * + * @param string $str 加密字符串 + * @return string 解码后的字符串 + */ + protected function decodePrice(string $str): string + { + // 映射表:PUA 字符 => 数字 / 万 + $map = [ + "\u{E53D}" => '1', + "\u{E3F0}" => '2', + "\u{E422}" => '3', + "\u{E42C}" => '4', + "\u{E49C}" => '5', + "\u{E42B}" => '6', + "\u{E4FE}" => '7', + "\u{E548}" => '8', + "\u{E4C8}" => '9', + "\u{E453}" => '0', + "\u{E45F}" => '万', + ]; + + return strtr($str, $map); + } + + /** + * 从URL中提取ID + * + * @param string $url URL + * @return int|false + */ + protected function extractIdFromUrl(string $url) + { + if (preg_match('/\/(\d+)$/', $url, $matches)) { + return (int)$matches[1]; + } + return false; + } + + /** + * 提取符合正则表达式的值 + * + * @param string $text 文本 + * @param string $pattern 正则表达式 + * @return string + */ + protected function extractPatternValue(string $text, string $pattern): string + { + $value = ''; + if ($text) { + if (preg_match($pattern, $text, $m)) { + $value = trim(preg_replace('/[^0-9.]/', '', $m[1])); + } + } + return $value; + } + + /** + * 下载单张图片 + * + * @param string $url 图片完整URL + * @param string $saveDir 保存目录 + * @param string|null $filename 指定文件名 + * @param array $headers 额外HTTP头 + * @return string|null 成功返回保存路径,失败返回null + */ + public function downloadImage(string $url, string $saveDir, ?string $filename = null, array $headers = []): ?string + { + // 目录检查 & 创建 + if (!is_dir($saveDir)) { + if (!mkdir($saveDir, 0777, true) && !is_dir($saveDir)) { + echo "[Image] 创建目录失败:{$saveDir}\n"; + return null; + } + } + + // 自动生成文件名 + if ($filename === null) { + $path = parse_url($url, PHP_URL_PATH); + $ext = pathinfo($path, PATHINFO_EXTENSION); + + if ($ext === '') { + $ext = 'jpg'; + } + + $filename = md5($url) . '.' . $ext; + } + + $savePath = rtrim($saveDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $filename; + + // 组装HTTP头 + $defaultHeaders = [ + 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Accept: image/avif,image/webp,image/apng,image/*,*/*;q=0.8', + 'Referer: https://www.dongchedi.com/', + ]; + + foreach ($headers as $h) { + $defaultHeaders[] = $h; + } + + // 使用cURL下载 + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 15, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + $data = curl_exec($ch); + $errNo = curl_errno($ch); + $errMsg = curl_error($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errNo !== 0) { + // echo "[Image] 下载失败(cURL):{$url} | {$errNo} {$errMsg}\n"; + return null; + } + + if ($code !== 200 || $data === false || $data === '') { + // echo "[Image] 下载失败(HTTP {$code}):{$url}\n"; + return null; + } + + // 写入本地文件 + $bytes = file_put_contents($savePath, $data); + if ($bytes === false) { + // echo "[Image] 保存失败:{$savePath}\n"; + return null; + } + + // echo "[Image] 下载成功:{$url} => {$savePath}\n"; + + return $savePath; + } + + /** + * 批量下载相册 + * + * @param array $urls 图片URL数组 + * @param string $saveDir 保存目录 + * @return array [原始URL => 保存路径或null] + */ + public function downloadImageAlbum(array $urls, string $saveDir): array + { + $result = []; + + foreach ($urls as $url) { + $url = trim($url); + if ($url === '') { + continue; + } + + // 补全协议 + if (strpos($url, '//') === 0) { + $url = 'https:' . $url; + } + + $path = $this->downloadImage($url, $saveDir); + $result[$url] = $path; + } + + return $result; + } + + + public function uploadAndRemove($images, $path, $usedCarId, $id) + { + // 下载图片 + $filePatch = $this->downloadImageAlbum($images, $path); + + // 检查图片 + if (empty($filePatch)) { + return false; + } + + // 上传图片 + foreach ($filePatch as $url => $path) { + if ($path === null) { + continue; + } + + // 上传云端 + $param = [ + 'path' => $path, + 'scene' => 'usedcar_crawl', + 'category' => 'usedcar' + ]; + $up = new \app\common\service\UploadService(); + $result = $up->uploadLocalFile($param); + + // 保存图片信息 + UsedcarCrawlResultsImages::create([ + 'uc_id' => $id, + 'used_car_id' => $usedCarId, + 'original_name' => $result['original_name'], + 'file_path' => $result['file_name'], + ]); + + // 删除本地文件 + @unlink($path); + } + } +} \ No newline at end of file diff --git a/app/common/validate/UsedcarCrawlResultsImagesValidate.php b/app/common/validate/UsedcarCrawlResultsImagesValidate.php new file mode 100644 index 0000000..e6cbbd5 --- /dev/null +++ b/app/common/validate/UsedcarCrawlResultsImagesValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/common/validate/UsedcarCrawlResultsValidate.php b/app/common/validate/UsedcarCrawlResultsValidate.php new file mode 100644 index 0000000..f0766bd --- /dev/null +++ b/app/common/validate/UsedcarCrawlResultsValidate.php @@ -0,0 +1,29 @@ + [ + + ], + + 'update' => [ + + ] + ]; +} \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index c3c75ae..4b4fb43 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -74,25 +74,25 @@ class IndexController $options = [ 'controller' => [ 'module' => 'common', - 'name' => 'finance_recon_dubai_files' + 'name' => 'usedcar_crawl_results_images' ], 'model' => [ 'module' => 'common', - 'name' => 'finance_recon_dubai_files' + 'name' => 'usedcar_crawl_results_images' ], 'service' => [ 'module' => 'common', - 'name' => 'finance_recon_dubai_files' + 'name' => 'usedcar_crawl_results_images' ], 'logic' => [ 'module' => 'common', - 'name' => 'finance_recon_dubai_files' + 'name' => 'usedcar_crawl_results_images' ], 'validate' => [ 'module' => 'common', - 'name' => 'finance_recon_dubai_files' + 'name' => 'usedcar_crawl_results_images' ], - 'table' => 'finance_recon_dubai_files' + 'table' => 'usedcar_crawl_results_images' ]; $result = (new Generate($options))->doGenerate(); @@ -159,11 +159,16 @@ class IndexController public function test(Request $request) { - $tmp = (new \app\common\service\OssService)->generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');; - - $service = new \app\common\service\FinanceRecoDubaiFilesService(); - $result = $service->import($tmp, true); + $service = new \app\common\utils\crawl\service\UsedCarCrawl(); - return json(['code' => 0, 'msg' => 'ok', 'data' => $result]); + $result = $service->pushUrl('https://www.dongchedi.com/usedcar/21631642'); + + // $result = $service->doData(); + + // $result = $service->decodeDongchediPrice('.'); + + // $result = $service->detectEnergyType('油电混合'); + + return json($result); } } \ No newline at end of file diff --git a/app/inquiry/utils/FbLeadHeaderMapper.php b/app/inquiry/utils/FbLeadHeaderMapper.php new file mode 100644 index 0000000..ed048ed --- /dev/null +++ b/app/inquiry/utils/FbLeadHeaderMapper.php @@ -0,0 +1,154 @@ + DB 字段名 + * 注意:key 用的是“原始表头字符串”(包括下划线) + */ + protected array $headerMap = [ + // ===== 基础公共字段 ===== + 'id' => 'fb_lead_id', + 'created_time' => 'fb_created_time', + 'ad_id' => 'ad_id', + 'ad_name' => 'ad_name', + 'adset_id' => 'adset_id', + 'adset_name' => 'adset_name', + 'campaign_id' => 'campaign_id', + 'campaign_name' => 'campaign_name', + 'form_id' => 'form_id', + 'form_name' => 'form_name', + 'is_organic' => 'is_organic', + 'platform' => 'platform', + + 'full_name' => 'full_name', + 'phone' => 'phone', + 'phone_number' => 'phone', // 兼容写法 + 'email' => 'email', + 'lead_status' => 'lead_status', + 'inbox_url' => 'inbox_url', + + // ===== 车型相关:全部归到 car_model ===== + 'which_model_of_this_car_would_you_like_to_purchase?_' => 'car_model', + 'which_model_of_this_car_would_you_like_to_purchase?' => 'car_model', + 'which_car_model_would_you_like_to_purchase?' => 'car_model', + 'which_model_do_you_want_to_purchase?' => 'car_model', + 'which_model_would_you_like_to_purchase?' => 'car_model', + 'which_version_do_you_want_to_buy?' => 'car_model', // 这里也归到车型/版本字段 + + // ===== 数量相关:全部归到 car_quantity ===== + 'how_many_cars_would_you_like_to_buy?_' => 'car_quantity', + 'how_many_cars_would_you_like_to_buy?' => 'car_quantity', + '_how_many_cars_would_you_like_to_buy?' => 'car_quantity', + 'how_many_cars_do_you_buy?' => 'car_quantity', + 'how_many_cars_do_you_want_to_purchase?' => 'car_quantity', + 'how_many_cars_do_you_want_to_buy?' => 'car_quantity', + 'what_quantity_do_you_want_to_buy?' => 'car_quantity', + + // ===== 颜色相关:归到 favorite_color ===== + 'what_color_do_you_like?' => 'favorite_color', + 'which_color_would_you_like_to_purchase?' => 'favorite_color', + + // ===== 用途相关:归到 usage_type ===== + 'are_you_using_it_for_business_or_personal_use?' => 'usage_type', + 'business_use_or_personal_use?' => 'usage_type', + 'personal_use_or_business_use?' => 'usage_type', + + // ===== 座位数相关 ===== + 'do_you_want_to_buy_six_seats_or_seven_seats?' => 'seat_preference', + ]; + + /** + * 返回所有支持的 Excel 表头 + */ + public function getSupportedHeaders(): array + { + return array_keys($this->headerMap); + } + + /** + * 单个表头映射:原始表头 -> DB 字段名 + */ + public function mapHeader(string $rawHeader): ?string + { + $key = $this->normalizeHeader($rawHeader); + + return $this->headerMap[$key] ?? null; + } + + /** + * 校验一整行表头: + * - 有任何不认识的表头,就抛 BusinessException + */ + public function validateHeaders(array $rawHeaders): void + { + $unsupported = []; + + foreach ($rawHeaders as $header) { + $header = (string)$header; + $key = $this->normalizeHeader($header); + + // 允许空表头(多余空列) + if ($key === '') { + continue; + } + + if (!isset($this->headerMap[$key])) { + $unsupported[] = $header; + } + } + + if (!empty($unsupported)) { + $msg = 'Excel 表头包含未兼容字段:' . implode('、', $unsupported) + . '。请检查这些字段是否需要加入映射配置。'; + throw new BusinessException($msg); + } + } + + /** + * 根据第一行表头,构建 “列索引 => DB 字段” 映射 + */ + public function buildColumnFieldMap(array $rawHeaders): array + { + // 先做一遍校验(发现问题直接抛异常) + $this->validateHeaders($rawHeaders); + + $colToField = []; + + foreach ($rawHeaders as $index => $header) { + $key = $this->normalizeHeader((string)$header); + if ($key === '') { + continue; + } + + $field = $this->headerMap[$key] ?? null; + if ($field === null) { + continue; // 理论上不会到这儿,双保险 + } + + $colToField[$index] = $field; + } + + return $colToField; + } + + /** + * 表头标准化: + * 目前只去 BOM + trim,保持和 headerMap 中 key 完全一致 + * 如果后面再出现“有空格/大小写”的版本,我们可以在这里做 strtolower / 替换空格 为 _ + */ + protected function normalizeHeader(string $header): string + { + // 去掉 UTF-8 BOM + $header = preg_replace('/^\xEF\xBB\xBF/', '', $header); + + // 去掉前后空白 + $header = trim($header); + + return $header; + } +} diff --git a/app/inquiry/utils/PhoneCountryHelper.php b/app/inquiry/utils/PhoneCountryHelper.php new file mode 100644 index 0000000..e6b0a48 --- /dev/null +++ b/app/inquiry/utils/PhoneCountryHelper.php @@ -0,0 +1,58 @@ + + + if (str_starts_with($phone, '00')) { + $phone = '+' . substr($phone, 2); + } elseif ($phone[0] !== '+') { + // 没有 +,默认补一个(大多数线索都是国际号) + $phone = '+' . $phone; + } + + $digits = ltrim($phone, '+'); + if ($digits === '') { + return null; + } + + // 3. 国家区号最长 3 位,从长到短匹配 + for ($len = 3; $len >= 1; $len--) { + $code = substr($digits, 0, $len); + if ($code === '') { + continue; + } + + $country = Db::table('country') + ->where('phone_code', $code) + ->first(); + + if ($country) { + return (int)$country->id; + } + } + + // 没找到 + return null; + } +} diff --git a/app/inquiry/utils/RoundRobinAssigner.php b/app/inquiry/utils/RoundRobinAssigner.php new file mode 100644 index 0000000..dc0ac77 --- /dev/null +++ b/app/inquiry/utils/RoundRobinAssigner.php @@ -0,0 +1,46 @@ + $v > 0)); + if (empty($userIds)) { + throw new BusinessException('轮询分配业务员失败:业务员列表不能为空'); + } + + $count = count($userIds); + + // Redis key:同一个 groupKey 共用一条轮询链 + $key = self::REDIS_KEY_PREFIX . $groupKey; + + // 使用 INCR 做自增计数,天然是原子操作,适合并发环境 + // 第一次调用 INCR 返回 1,我们用 (value - 1) 来做索引,从 0 开始 + $val = Redis::incr($key); // 如果不存在,Redis 会先置为 0 再加 1 + + $index = ($val - 1) % $count; + + return $userIds[$index]; + } +} diff --git a/app/process/CheckWeb.php b/app/process/CheckWeb.php index 8b99d61..f3439ef 100644 --- a/app/process/CheckWeb.php +++ b/app/process/CheckWeb.php @@ -22,6 +22,12 @@ class CheckWeb // }); // } + // 每秒执行:队列二手车数据处理 + new Crontab('* * * * * *', function(){ + $service = new \app\common\utils\crawl\service\UsedCarCrawl(); + $service->execute(); + }); + new Crontab('*/50 * * * * *', function(){ $userIds = User::where('status', 1)->pluck('id')->toArray(); foreach ($userIds as $userId) { diff --git a/app/process/Task.php b/app/process/Task.php index 9675b28..47fe78f 100644 --- a/app/process/Task.php +++ b/app/process/Task.php @@ -15,7 +15,7 @@ class Task { // 每半小时执行一次,通知待跟进客户 new Crontab('0 */30 * * * *', function(){ - echo date('Y-m-d H:i:s') . "\n"; + // echo date('Y-m-d H:i:s') . "\n"; Log::info('send follow start',[time()]); $where = [ diff --git a/composer.json b/composer.json index a696240..d502105 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,8 @@ "webman/gateway-worker": "^1.0", "mpdf/mpdf": "^8.2", "jaeger/querylist": "^4.4", - "zoujingli/ip2region": "^3.0" + "zoujingli/ip2region": "^3.0", + "webman/log": "^2.1" }, "suggest": { "ext-event": "For better performance. " diff --git a/composer.lock b/composer.lock index ba35bd5..ecd2fc5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ac2fa5a625069729975d072614509e1f", + "content-hash": "bf987c3cd5510fc0637d6b64d460dde1", "packages": [ { "name": "aliyuncs/oss-sdk-php", @@ -18,13 +18,7 @@ "type": "zip", "url": "https://api.github.com/repos/aliyun/aliyun-oss-php-sdk/zipball/483dd0b8bff5d47f0e4ffc99f6077a295c5ccbb5", "reference": "483dd0b8bff5d47f0e4ffc99f6077a295c5ccbb5", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "shasum": "" }, "require": { "php": ">=5.3" @@ -59,25 +53,25 @@ }, { "name": "brick/math", - "version": "0.13.1", + "version": "0.14.1", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04" + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/fc7ed316430118cc7836bf45faff18d5dfc8de04", - "reference": "fc7ed316430118cc7836bf45faff18d5dfc8de04", + "url": "https://api.github.com/repos/brick/math/zipball/f05858549e5f9d7bb45875a75583240a38a281d0", + "reference": "f05858549e5f9d7bb45875a75583240a38a281d0", "shasum": "" }, "require": { - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "php-coveralls/php-coveralls": "^2.2", - "phpunit/phpunit": "^10.1", - "vimeo/psalm": "6.8.8" + "phpstan/phpstan": "2.1.22", + "phpunit/phpunit": "^11.5" }, "type": "library", "autoload": { @@ -107,7 +101,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.13.1" + "source": "https://github.com/brick/math/tree/0.14.1" }, "funding": [ { @@ -115,7 +109,7 @@ "type": "github" } ], - "time": "2025-03-29T13:50:30+00:00" + "time": "2025-11-24T14:40:29+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -267,33 +261,32 @@ }, { "name": "doctrine/inflector", - "version": "2.0.10", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/doctrine/inflector.git", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc" + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc", - "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", "shasum": "" }, "require": { "php": "^7.2 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^11.0", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.3", - "phpunit/phpunit": "^8.5 || ^9.5", - "vimeo/psalm": "^4.25 || ^5.4" + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" }, "type": "library", "autoload": { "psr-4": { - "Doctrine\\Inflector\\": "lib/Doctrine/Inflector" + "Doctrine\\Inflector\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -338,7 +331,7 @@ ], "support": { "issues": "https://github.com/doctrine/inflector/issues", - "source": "https://github.com/doctrine/inflector/tree/2.0.10" + "source": "https://github.com/doctrine/inflector/tree/2.1.0" }, "funding": [ { @@ -354,7 +347,7 @@ "type": "tidelift" } ], - "time": "2024-02-18T20:23:39+00:00" + "time": "2025-08-10T19:31:58+00:00" }, { "name": "firebase/php-jwt", @@ -421,31 +414,31 @@ }, { "name": "fruitcake/php-cors", - "version": "v1.3.0", + "version": "v1.4.0", "source": { "type": "git", "url": "https://github.com/fruitcake/php-cors.git", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b" + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/3d158f36e7875e2f040f37bc0573956240a5a38b", - "reference": "3d158f36e7875e2f040f37bc0573956240a5a38b", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", "shasum": "" }, "require": { - "php": "^7.4|^8.0", - "symfony/http-foundation": "^4.4|^5.4|^6|^7" + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" }, "require-dev": { - "phpstan/phpstan": "^1.4", + "phpstan/phpstan": "^2", "phpunit/phpunit": "^9", - "squizlabs/php_codesniffer": "^3.5" + "squizlabs/php_codesniffer": "^4" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.2-dev" + "dev-master": "1.3-dev" } }, "autoload": { @@ -476,7 +469,7 @@ ], "support": { "issues": "https://github.com/fruitcake/php-cors/issues", - "source": "https://github.com/fruitcake/php-cors/tree/v1.3.0" + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" }, "funding": [ { @@ -488,7 +481,7 @@ "type": "github" } ], - "time": "2023-10-12T05:21:21+00:00" + "time": "2025-12-03T09:33:47+00:00" }, { "name": "graham-campbell/result-type", @@ -554,22 +547,22 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.9.3", + "version": "7.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77" + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", - "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", "shasum": "" }, "require": { "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.3", - "guzzlehttp/psr7": "^2.7.0", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", "php": "^7.2.5 || ^8.0", "psr/http-client": "^1.0", "symfony/deprecation-contracts": "^2.2 || ^3.0" @@ -660,7 +653,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.9.3" + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" }, "funding": [ { @@ -676,20 +669,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:37:11+00:00" + "time": "2025-08-23T22:36:01+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.2.0", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c" + "reference": "481557b130ef3790cf82b713667b43030dc9c957" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c", - "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c", + "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", "shasum": "" }, "require": { @@ -697,7 +690,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "type": "library", "extra": { @@ -743,7 +736,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.2.0" + "source": "https://github.com/guzzle/promises/tree/2.3.0" }, "funding": [ { @@ -759,20 +752,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T13:27:01+00:00" + "time": "2025-08-22T14:34:08+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.7.1", + "version": "2.8.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16" + "reference": "21dc724a0583619cd1652f673303492272778051" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16", - "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", + "reference": "21dc724a0583619cd1652f673303492272778051", "shasum": "" }, "require": { @@ -788,7 +781,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", - "phpunit/phpunit": "^8.5.39 || ^9.6.20" + "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -859,7 +852,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.7.1" + "source": "https://github.com/guzzle/psr7/tree/2.8.0" }, "funding": [ { @@ -875,20 +868,20 @@ "type": "tidelift" } ], - "time": "2025-03-27T12:30:47+00:00" + "time": "2025-08-23T21:21:41+00:00" }, { "name": "guzzlehttp/uri-template", - "version": "v1.0.4", + "version": "v1.0.5", "source": { "type": "git", "url": "https://github.com/guzzle/uri-template.git", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2" + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/uri-template/zipball/30e286560c137526eccd4ce21b2de477ab0676d2", - "reference": "30e286560c137526eccd4ce21b2de477ab0676d2", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/4f4bbd4e7172148801e76e3decc1e559bdee34e1", + "reference": "4f4bbd4e7172148801e76e3decc1e559bdee34e1", "shasum": "" }, "require": { @@ -897,7 +890,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", + "phpunit/phpunit": "^8.5.44 || ^9.6.25", "uri-template/tests": "1.0.0" }, "type": "library", @@ -945,7 +938,7 @@ ], "support": { "issues": "https://github.com/guzzle/uri-template/issues", - "source": "https://github.com/guzzle/uri-template/tree/v1.0.4" + "source": "https://github.com/guzzle/uri-template/tree/v1.0.5" }, "funding": [ { @@ -961,20 +954,20 @@ "type": "tidelift" } ], - "time": "2025-02-03T10:55:03+00:00" + "time": "2025-08-22T14:27:06+00:00" }, { "name": "illuminate/bus", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/bus.git", - "reference": "60da78ea881c539ce56c5b66321be73755c5918c" + "reference": "fdb568a20ad96461caa83b98ffc36d9e95894491" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/bus/zipball/60da78ea881c539ce56c5b66321be73755c5918c", - "reference": "60da78ea881c539ce56c5b66321be73755c5918c", + "url": "https://api.github.com/repos/illuminate/bus/zipball/fdb568a20ad96461caa83b98ffc36d9e95894491", + "reference": "fdb568a20ad96461caa83b98ffc36d9e95894491", "shasum": "" }, "require": { @@ -1014,27 +1007,29 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-13T15:08:45+00:00" + "time": "2025-11-30T17:14:13+00:00" }, { "name": "illuminate/collections", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "e5711846b7c68128bc8de72c13e017606043c996" + "reference": "16657effa6a5a4e728f9aeb3e38fb4fa9ba70e7d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/e5711846b7c68128bc8de72c13e017606043c996", - "reference": "e5711846b7c68128bc8de72c13e017606043c996", + "url": "https://api.github.com/repos/illuminate/collections/zipball/16657effa6a5a4e728f9aeb3e38fb4fa9ba70e7d", + "reference": "16657effa6a5a4e728f9aeb3e38fb4fa9ba70e7d", "shasum": "" }, "require": { "illuminate/conditionable": "^12.0", "illuminate/contracts": "^12.0", "illuminate/macroable": "^12.0", - "php": "^8.2" + "php": "^8.2", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" }, "suggest": { "illuminate/http": "Required to convert collections to API resources (^12.0).", @@ -1071,11 +1066,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-28T13:08:33+00:00" + "time": "2025-12-06T18:08:25+00:00" }, { "name": "illuminate/conditionable", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1121,26 +1116,37 @@ }, { "name": "illuminate/container", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/container.git", - "reference": "ff9dde2c8dce16ea9ecf0418095749311240aff9" + "reference": "326667a4c813e3ad5a645969a7e3f5c10d159de2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/container/zipball/ff9dde2c8dce16ea9ecf0418095749311240aff9", - "reference": "ff9dde2c8dce16ea9ecf0418095749311240aff9", + "url": "https://api.github.com/repos/illuminate/container/zipball/326667a4c813e3ad5a645969a7e3f5c10d159de2", + "reference": "326667a4c813e3ad5a645969a7e3f5c10d159de2", "shasum": "" }, "require": { "illuminate/contracts": "^12.0", + "illuminate/reflection": "^12.0", "php": "^8.2", - "psr/container": "^1.1.1|^2.0.1" + "psr/container": "^1.1.1|^2.0.1", + "symfony/polyfill-php84": "^1.33", + "symfony/polyfill-php85": "^1.33" }, "provide": { "psr/container-implementation": "1.1|2.0" }, + "suggest": { + "illuminate/auth": "Required to use the Auth attribute", + "illuminate/cache": "Required to use the Cache attribute", + "illuminate/config": "Required to use the Config attribute", + "illuminate/database": "Required to use the DB attribute", + "illuminate/filesystem": "Required to use the Storage attribute", + "illuminate/log": "Required to use the Log or Context attributes" + }, "type": "library", "extra": { "branch-alias": { @@ -1168,20 +1174,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-09T14:04:48+00:00" + "time": "2025-12-08T22:34:25+00:00" }, { "name": "illuminate/contracts", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", - "reference": "152313571553ef1be907a3c18b8ef2d635cb4339" + "reference": "19e8938edb73047017cfbd443b96844b86da4a59" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/contracts/zipball/152313571553ef1be907a3c18b8ef2d635cb4339", - "reference": "152313571553ef1be907a3c18b8ef2d635cb4339", + "url": "https://api.github.com/repos/illuminate/contracts/zipball/19e8938edb73047017cfbd443b96844b86da4a59", + "reference": "19e8938edb73047017cfbd443b96844b86da4a59", "shasum": "" }, "require": { @@ -1216,24 +1222,24 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-09T18:23:03+00:00" + "time": "2025-11-26T21:36:01+00:00" }, { "name": "illuminate/database", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/database.git", - "reference": "da274285e4c09f6794945354b5b11157f4d9cab1" + "reference": "b44f021fee7b48289312e22fd6e4d580374a86ac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/database/zipball/da274285e4c09f6794945354b5b11157f4d9cab1", - "reference": "da274285e4c09f6794945354b5b11157f4d9cab1", + "url": "https://api.github.com/repos/illuminate/database/zipball/b44f021fee7b48289312e22fd6e4d580374a86ac", + "reference": "b44f021fee7b48289312e22fd6e4d580374a86ac", "shasum": "" }, "require": { - "brick/math": "^0.11|^0.12|^0.13", + "brick/math": "^0.11|^0.12|^0.13|^0.14", "ext-pdo": "*", "illuminate/collections": "^12.0", "illuminate/container": "^12.0", @@ -1241,7 +1247,8 @@ "illuminate/macroable": "^12.0", "illuminate/support": "^12.0", "laravel/serializable-closure": "^1.3|^2.0", - "php": "^8.2" + "php": "^8.2", + "symfony/polyfill-php85": "^1.33" }, "suggest": { "ext-filter": "Required to use the Postgres database driver.", @@ -1286,20 +1293,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-09T14:07:55+00:00" + "time": "2025-12-06T18:08:25+00:00" }, { "name": "illuminate/events", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/events.git", - "reference": "bf1f121ea51e077e893d32e2848e102513d4b1b5" + "reference": "5fbf9a127cb649699071c2fe98ac1d39e4991da3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/events/zipball/bf1f121ea51e077e893d32e2848e102513d4b1b5", - "reference": "bf1f121ea51e077e893d32e2848e102513d4b1b5", + "url": "https://api.github.com/repos/illuminate/events/zipball/5fbf9a127cb649699071c2fe98ac1d39e4991da3", + "reference": "5fbf9a127cb649699071c2fe98ac1d39e4991da3", "shasum": "" }, "require": { @@ -1341,20 +1348,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-13T15:08:45+00:00" + "time": "2025-12-01T15:02:02+00:00" }, { "name": "illuminate/filesystem", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/filesystem.git", - "reference": "80fe8605cfa360fdbc85f67c19801a9657615aab" + "reference": "b1fbb20010e868f838feac86aeac8ba439fca10d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/filesystem/zipball/80fe8605cfa360fdbc85f67c19801a9657615aab", - "reference": "80fe8605cfa360fdbc85f67c19801a9657615aab", + "url": "https://api.github.com/repos/illuminate/filesystem/zipball/b1fbb20010e868f838feac86aeac8ba439fca10d", + "reference": "b1fbb20010e868f838feac86aeac8ba439fca10d", "shasum": "" }, "require": { @@ -1408,20 +1415,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-13T15:08:45+00:00" + "time": "2025-10-29T15:59:33+00:00" }, { "name": "illuminate/http", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/http.git", - "reference": "bbafb2b2e629530e516d52a1fa46dfeb09b5e184" + "reference": "b7997f2faffb3c31f2a0fa079428ff6bfcce8907" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/http/zipball/bbafb2b2e629530e516d52a1fa46dfeb09b5e184", - "reference": "bbafb2b2e629530e516d52a1fa46dfeb09b5e184", + "url": "https://api.github.com/repos/illuminate/http/zipball/b7997f2faffb3c31f2a0fa079428ff6bfcce8907", + "reference": "b7997f2faffb3c31f2a0fa079428ff6bfcce8907", "shasum": "" }, "require": { @@ -1437,7 +1444,8 @@ "symfony/http-foundation": "^7.2.0", "symfony/http-kernel": "^7.2.0", "symfony/mime": "^7.2.0", - "symfony/polyfill-php83": "^1.31" + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php85": "^1.33" }, "suggest": { "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image()." @@ -1469,11 +1477,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-06T18:14:49+00:00" + "time": "2025-12-09T15:25:50+00:00" }, { "name": "illuminate/macroable", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -1519,16 +1527,16 @@ }, { "name": "illuminate/pagination", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/pagination.git", - "reference": "7f68669ad7d8a706883b8409f6b7254fdc437f67" + "reference": "87e7e3e7b02d6809b1bcd41782e1ca2c6d2a413b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/pagination/zipball/7f68669ad7d8a706883b8409f6b7254fdc437f67", - "reference": "7f68669ad7d8a706883b8409f6b7254fdc437f67", + "url": "https://api.github.com/repos/illuminate/pagination/zipball/87e7e3e7b02d6809b1bcd41782e1ca2c6d2a413b", + "reference": "87e7e3e7b02d6809b1bcd41782e1ca2c6d2a413b", "shasum": "" }, "require": { @@ -1565,27 +1573,31 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-28T21:37:18+00:00" + "time": "2025-11-16T14:36:17+00:00" }, { "name": "illuminate/pipeline", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/pipeline.git", - "reference": "a1039dfe54854470cdda37782bab0901aa588dd4" + "reference": "b6a14c20d69a44bf0a6fba664a00d23ca71770ee" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/pipeline/zipball/a1039dfe54854470cdda37782bab0901aa588dd4", - "reference": "a1039dfe54854470cdda37782bab0901aa588dd4", + "url": "https://api.github.com/repos/illuminate/pipeline/zipball/b6a14c20d69a44bf0a6fba664a00d23ca71770ee", + "reference": "b6a14c20d69a44bf0a6fba664a00d23ca71770ee", "shasum": "" }, "require": { "illuminate/contracts": "^12.0", + "illuminate/macroable": "^12.0", "illuminate/support": "^12.0", "php": "^8.2" }, + "suggest": { + "illuminate/database": "Required to use database transactions (^12.0)." + }, "type": "library", "extra": { "branch-alias": { @@ -1613,20 +1625,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-13T15:08:45+00:00" + "time": "2025-08-20T13:36:50+00:00" }, { "name": "illuminate/redis", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/redis.git", - "reference": "b225dc28092b00ed0555df66ce3a3a448b4dd8ae" + "reference": "8dc6682638100db9feceaac3db206de752dd0f9a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/redis/zipball/b225dc28092b00ed0555df66ce3a3a448b4dd8ae", - "reference": "b225dc28092b00ed0555df66ce3a3a448b4dd8ae", + "url": "https://api.github.com/repos/illuminate/redis/zipball/8dc6682638100db9feceaac3db206de752dd0f9a", + "reference": "8dc6682638100db9feceaac3db206de752dd0f9a", "shasum": "" }, "require": { @@ -1667,20 +1679,71 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-13T15:08:45+00:00" + "time": "2025-11-20T20:50:40+00:00" }, { - "name": "illuminate/session", - "version": "v12.18.0", + "name": "illuminate/reflection", + "version": "v12.42.0", "source": { "type": "git", - "url": "https://github.com/illuminate/session.git", - "reference": "9d27155b34bca502fe3e1adc16035b46d5ef3ed8" + "url": "https://github.com/illuminate/reflection.git", + "reference": "7b86bc570d5b75e4a3ad79f8cca1491ba24b7c75" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/session/zipball/9d27155b34bca502fe3e1adc16035b46d5ef3ed8", - "reference": "9d27155b34bca502fe3e1adc16035b46d5ef3ed8", + "url": "https://api.github.com/repos/illuminate/reflection/zipball/7b86bc570d5b75e4a3ad79f8cca1491ba24b7c75", + "reference": "7b86bc570d5b75e4a3ad79f8cca1491ba24b7c75", + "shasum": "" + }, + "require": { + "illuminate/collections": "^12.0", + "illuminate/contracts": "^12.0", + "php": "^8.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "12.x-dev" + } + }, + "autoload": { + "files": [ + "helpers.php" + ], + "psr-4": { + "Illuminate\\Support\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Illuminate Reflection package.", + "homepage": "https://laravel.com", + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2025-12-09T15:11:22+00:00" + }, + { + "name": "illuminate/session", + "version": "v12.42.0", + "source": { + "type": "git", + "url": "https://github.com/illuminate/session.git", + "reference": "c376e4a0dda79a88462e8a7d16cc924aa3eb6e15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/illuminate/session/zipball/c376e4a0dda79a88462e8a7d16cc924aa3eb6e15", + "reference": "c376e4a0dda79a88462e8a7d16cc924aa3eb6e15", "shasum": "" }, "require": { @@ -1724,20 +1787,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-05-13T15:08:45+00:00" + "time": "2025-10-26T17:11:19+00:00" }, { "name": "illuminate/support", - "version": "v12.18.0", + "version": "v12.42.0", "source": { "type": "git", "url": "https://github.com/illuminate/support.git", - "reference": "3c3894ba09a86663b747efaa6822639f1c32ea01" + "reference": "d35411be5657e0b5560a5885f3c9140e4cbe0be5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/support/zipball/3c3894ba09a86663b747efaa6822639f1c32ea01", - "reference": "3c3894ba09a86663b747efaa6822639f1c32ea01", + "url": "https://api.github.com/repos/illuminate/support/zipball/d35411be5657e0b5560a5885f3c9140e4cbe0be5", + "reference": "d35411be5657e0b5560a5885f3c9140e4cbe0be5", "shasum": "" }, "require": { @@ -1749,8 +1812,11 @@ "illuminate/conditionable": "^12.0", "illuminate/contracts": "^12.0", "illuminate/macroable": "^12.0", + "illuminate/reflection": "^12.0", "nesbot/carbon": "^3.8.4", "php": "^8.2", + "symfony/polyfill-php83": "^1.33", + "symfony/polyfill-php85": "^1.33", "voku/portable-ascii": "^2.0.2" }, "conflict": { @@ -1801,7 +1867,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2025-06-06T18:15:26+00:00" + "time": "2025-12-09T15:26:52+00:00" }, { "name": "jaeger/g-http", @@ -1960,16 +2026,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.4", + "version": "v2.0.7", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841" + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/b352cf0534aa1ae6b4d825d1e762e35d43f8a841", - "reference": "b352cf0534aa1ae6b4d825d1e762e35d43f8a841", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/cb291e4c998ac50637c7eeb58189c14f5de5b9dd", + "reference": "cb291e4c998ac50637c7eeb58189c14f5de5b9dd", "shasum": "" }, "require": { @@ -1978,7 +2044,7 @@ "require-dev": { "illuminate/support": "^10.0|^11.0|^12.0", "nesbot/carbon": "^2.67|^3.0", - "pestphp/pest": "^2.36|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", "phpstan/phpstan": "^2.0", "symfony/var-dumper": "^6.2.0|^7.0.0" }, @@ -2017,27 +2083,21 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2025-03-19T13:51:03+00:00" + "time": "2025-11-21T20:52:36+00:00" }, { "name": "league/flysystem", - "version": "3.29.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "0adc0d9a51852e170e0028a60bd271726626d3f0" + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/0adc0d9a51852e170e0028a60bd271726626d3f0", - "reference": "0adc0d9a51852e170e0028a60bd271726626d3f0", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "reference": "5966a8ba23e62bdb518dd9e0e665c2dbd4b5b277", + "shasum": "" }, "require": { "league/flysystem-local": "^3.0.0", @@ -2060,13 +2120,13 @@ "composer/semver": "^3.0", "ext-fileinfo": "*", "ext-ftp": "*", - "ext-mongodb": "^1.3", + "ext-mongodb": "^1.3|^2", "ext-zip": "*", "friendsofphp/php-cs-fixer": "^3.5", "google/cloud-storage": "^1.23", "guzzlehttp/psr7": "^2.6", "microsoft/azure-storage-blob": "^1.1", - "mongodb/mongodb": "^1.2", + "mongodb/mongodb": "^1.2|^2", "phpseclib/phpseclib": "^3.0.36", "phpstan/phpstan": "^1.10", "phpunit/phpunit": "^9.5.11|^10.0", @@ -2104,29 +2164,23 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.30.2" }, - "time": "2024-09-29T11:59:11+00:00" + "time": "2025-11-10T17:13:11+00:00" }, { "name": "league/flysystem-local", - "version": "3.29.0", + "version": "3.30.2", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem-local.git", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27" + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/e0e8d52ce4b2ed154148453d321e97c8e931bd27", - "reference": "e0e8d52ce4b2ed154148453d321e97c8e931bd27", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/ab4f9d0d672f601b102936aa728801dd1a11968d", + "reference": "ab4f9d0d672f601b102936aa728801dd1a11968d", + "shasum": "" }, "require": { "ext-fileinfo": "*", @@ -2159,29 +2213,23 @@ "local" ], "support": { - "source": "https://github.com/thephpleague/flysystem-local/tree/3.29.0" + "source": "https://github.com/thephpleague/flysystem-local/tree/3.30.2" }, - "time": "2024-08-09T21:24:39+00:00" + "time": "2025-11-10T11:23:37+00:00" }, { "name": "league/mime-type-detection", - "version": "1.15.0", + "version": "1.16.0", "source": { "type": "git", "url": "https://github.com/thephpleague/mime-type-detection.git", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301" + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", - "reference": "ce0f4d1e8a6f4eb0ddff33f57c69c50fd09f4301", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/2d6702ff215bf922936ccc1ad31007edc76451b9", + "reference": "2d6702ff215bf922936ccc1ad31007edc76451b9", + "shasum": "" }, "require": { "ext-fileinfo": "*", @@ -2211,7 +2259,7 @@ "description": "Mime-type detection for Flysystem", "support": { "issues": "https://github.com/thephpleague/mime-type-detection/issues", - "source": "https://github.com/thephpleague/mime-type-detection/tree/1.15.0" + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.16.0" }, "funding": [ { @@ -2223,26 +2271,26 @@ "type": "tidelift" } ], - "time": "2024-01-28T23:22:08+00:00" + "time": "2024-09-21T08:32:55+00:00" }, { "name": "maennchen/zipstream-php", - "version": "3.1.2", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/maennchen/ZipStream-PHP.git", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", - "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416", + "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416", "shasum": "" }, "require": { "ext-mbstring": "*", "ext-zlib": "*", - "php-64bit": "^8.2" + "php-64bit": "^8.3" }, "require-dev": { "brianium/paratest": "^7.7", @@ -2251,7 +2299,7 @@ "guzzlehttp/guzzle": "^7.5", "mikey179/vfsstream": "^1.6", "php-coveralls/php-coveralls": "^2.5", - "phpunit/phpunit": "^11.0", + "phpunit/phpunit": "^12.0", "vimeo/psalm": "^6.0" }, "suggest": { @@ -2293,7 +2341,7 @@ ], "support": { "issues": "https://github.com/maennchen/ZipStream-PHP/issues", - "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0" }, "funding": [ { @@ -2301,7 +2349,7 @@ "type": "github" } ], - "time": "2025-01-27T12:07:53+00:00" + "time": "2025-07-17T11:15:13+00:00" }, { "name": "markbaker/complex", @@ -2514,16 +2562,16 @@ }, { "name": "mpdf/mpdf", - "version": "v8.2.6", + "version": "v8.2.7", "source": { "type": "git", "url": "https://github.com/mpdf/mpdf.git", - "reference": "dd30e3b01061cf8dfe65e7041ab4cc46d8ebdd44" + "reference": "b59670a09498689c33ce639bac8f5ba26721dab3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/mpdf/mpdf/zipball/dd30e3b01061cf8dfe65e7041ab4cc46d8ebdd44", - "reference": "dd30e3b01061cf8dfe65e7041ab4cc46d8ebdd44", + "url": "https://api.github.com/repos/mpdf/mpdf/zipball/b59670a09498689c33ce639bac8f5ba26721dab3", + "reference": "b59670a09498689c33ce639bac8f5ba26721dab3", "shasum": "" }, "require": { @@ -2591,7 +2639,7 @@ "type": "custom" } ], - "time": "2025-08-18T08:51:51+00:00" + "time": "2025-12-01T10:18:02+00:00" }, { "name": "mpdf/psr-http-message-shim", @@ -2747,16 +2795,16 @@ }, { "name": "nesbot/carbon", - "version": "3.10.0", + "version": "3.11.0", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9" + "reference": "bdb375400dcd162624531666db4799b36b64e4a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/c1397390dd0a7e0f11660f0ae20f753d88c1f3d9", - "reference": "c1397390dd0a7e0f11660f0ae20f753d88c1f3d9", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/bdb375400dcd162624531666db4799b36b64e4a1", + "reference": "bdb375400dcd162624531666db4799b36b64e4a1", "shasum": "" }, "require": { @@ -2764,9 +2812,9 @@ "ext-json": "*", "php": "^8.1", "psr/clock": "^1.0", - "symfony/clock": "^6.3.12 || ^7.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", "symfony/polyfill-mbstring": "^1.0", - "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0" + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" }, "provide": { "psr/clock-implementation": "1.0" @@ -2774,13 +2822,13 @@ "require-dev": { "doctrine/dbal": "^3.6.3 || ^4.0", "doctrine/orm": "^2.15.2 || ^3.0", - "friendsofphp/php-cs-fixer": "^3.75.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", "kylekatarnls/multi-tester": "^2.5.3", "phpmd/phpmd": "^2.15.0", "phpstan/extension-installer": "^1.4.3", - "phpstan/phpstan": "^2.1.17", - "phpunit/phpunit": "^10.5.46", - "squizlabs/php_codesniffer": "^3.13.0" + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4" }, "bin": [ "bin/carbon" @@ -2848,7 +2896,7 @@ "type": "tidelift" } ], - "time": "2025-06-12T10:24:28+00:00" + "time": "2025-12-02T21:04:28+00:00" }, { "name": "nikic/fast-route", @@ -2952,16 +3000,16 @@ }, { "name": "phpmailer/phpmailer", - "version": "v6.10.0", + "version": "v6.12.0", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144" + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", - "reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/d1ac35d784bf9f5e61b424901d5a014967f15b12", + "reference": "d1ac35d784bf9f5e61b424901d5a014967f15b12", "shasum": "" }, "require": { @@ -3021,7 +3069,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.10.0" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.12.0" }, "funding": [ { @@ -3029,7 +3077,7 @@ "type": "github" } ], - "time": "2025-04-24T15:19:31+00:00" + "time": "2025-10-15T16:49:08+00:00" }, { "name": "phpoffice/phpspreadsheet", @@ -3139,16 +3187,16 @@ }, { "name": "phpoption/phpoption", - "version": "1.9.3", + "version": "1.9.4", "source": { "type": "git", "url": "https://github.com/schmittjoh/php-option.git", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54" + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/e3fac8b24f56113f7cb96af14958c0dd16330f54", - "reference": "e3fac8b24f56113f7cb96af14958c0dd16330f54", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", + "reference": "638a154f8d4ee6a5cfa96d6a34dfbe0cffa9566d", "shasum": "" }, "require": { @@ -3156,7 +3204,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.39 || ^9.6.20 || ^10.5.28" + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" }, "type": "library", "extra": { @@ -3198,7 +3246,7 @@ ], "support": { "issues": "https://github.com/schmittjoh/php-option/issues", - "source": "https://github.com/schmittjoh/php-option/tree/1.9.3" + "source": "https://github.com/schmittjoh/php-option/tree/1.9.4" }, "funding": [ { @@ -3210,7 +3258,7 @@ "type": "tidelift" } ], - "time": "2024-07-20T21:41:07+00:00" + "time": "2025-08-21T11:53:16+00:00" }, { "name": "psr/cache", @@ -3801,13 +3849,7 @@ "type": "zip", "url": "https://api.github.com/repos/shopwwi/filesystem-oss/zipball/85cb385d1a5d11ca3c22bc485a7b18c588fe7645", "reference": "85cb385d1a5d11ca3c22bc485a7b18c588fe7645", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "shasum": "" }, "require": { "aliyuncs/oss-sdk-php": "^2.4", @@ -3860,13 +3902,7 @@ "type": "zip", "url": "https://api.github.com/repos/shopwwi/webman-filesystem/zipball/26ac82515d3787943573c50d1a6f0ca339f0d3d4", "reference": "26ac82515d3787943573c50d1a6f0ca339f0d3d4", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "shasum": "" }, "require": { "league/flysystem": "^2.3|^3.0", @@ -3909,16 +3945,16 @@ }, { "name": "symfony/cache", - "version": "v7.3.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "c4b217b578c11ec764867aa0c73e602c602965de" + "reference": "21e0755783bbbab58f2bb6a7a57896d21d27a366" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/c4b217b578c11ec764867aa0c73e602c602965de", - "reference": "c4b217b578c11ec764867aa0c73e602c602965de", + "url": "https://api.github.com/repos/symfony/cache/zipball/21e0755783bbbab58f2bb6a7a57896d21d27a366", + "reference": "21e0755783bbbab58f2bb6a7a57896d21d27a366", "shasum": "" }, "require": { @@ -3926,12 +3962,14 @@ "psr/cache": "^2.0|^3.0", "psr/log": "^1.1|^2|^3", "symfony/cache-contracts": "^3.6", - "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/service-contracts": "^2.5|^3", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "conflict": { "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", "symfony/dependency-injection": "<6.4", "symfony/http-kernel": "<6.4", "symfony/var-dumper": "<6.4" @@ -3946,13 +3984,13 @@ "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", "psr/simple-cache": "^1.0|^2.0|^3.0", - "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/filesystem": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -3987,7 +4025,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v7.3.0" + "source": "https://github.com/symfony/cache/tree/v7.4.1" }, "funding": [ { @@ -3998,12 +4036,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-06T19:00:13+00:00" + "time": "2025-12-04T18:11:45+00:00" }, { "name": "symfony/cache-contracts", @@ -4083,16 +4125,16 @@ }, { "name": "symfony/clock", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24" + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", - "reference": "b81435fbd6648ea425d1ee96a2d8e68f4ceacd24", + "url": "https://api.github.com/repos/symfony/clock/zipball/9169f24776edde469914c1e7a1442a50f7a4e110", + "reference": "9169f24776edde469914c1e7a1442a50f7a4e110", "shasum": "" }, "require": { @@ -4137,7 +4179,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v7.3.0" + "source": "https://github.com/symfony/clock/tree/v7.4.0" }, "funding": [ { @@ -4148,25 +4190,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-25T14:21:43+00:00" + "time": "2025-11-12T15:39:26+00:00" }, { "name": "symfony/console", - "version": "v7.3.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44" + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/66c1440edf6f339fd82ed6c7caa76cb006211b44", - "reference": "66c1440edf6f339fd82ed6c7caa76cb006211b44", + "url": "https://api.github.com/repos/symfony/console/zipball/6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", + "reference": "6d9f0fbf2ec2e9785880096e3abd0ca0c88b506e", "shasum": "" }, "require": { @@ -4174,7 +4220,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "conflict": { "symfony/dependency-injection": "<6.4", @@ -4188,16 +4234,16 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4231,7 +4277,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.3.0" + "source": "https://github.com/symfony/console/tree/v7.4.1" }, "funding": [ { @@ -4242,12 +4288,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-24T10:34:04+00:00" + "time": "2025-12-05T15:23:39+00:00" }, { "name": "symfony/deprecation-contracts", @@ -4318,32 +4368,33 @@ }, { "name": "symfony/error-handler", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "cf68d225bc43629de4ff54778029aee6dc191b83" + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/cf68d225bc43629de4ff54778029aee6dc191b83", - "reference": "cf68d225bc43629de4ff54778029aee6dc191b83", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/48be2b0653594eea32dcef130cca1c811dcf25c2", + "reference": "48be2b0653594eea32dcef130cca1c811dcf25c2", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "conflict": { "symfony/deprecation-contracts": "<2.5", "symfony/http-kernel": "<6.4" }, "require-dev": { - "symfony/console": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", "symfony/webpack-encore-bundle": "^1.0|^2.0" }, "bin": [ @@ -4375,7 +4426,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.3.0" + "source": "https://github.com/symfony/error-handler/tree/v7.4.0" }, "funding": [ { @@ -4386,25 +4437,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-29T07:19:49+00:00" + "time": "2025-11-05T14:29:59+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d" + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/497f73ac996a598c92409b44ac43b6690c4f666d", - "reference": "497f73ac996a598c92409b44ac43b6690c4f666d", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/9dddcddff1ef974ad87b3708e4b442dc38b2261d", + "reference": "9dddcddff1ef974ad87b3708e4b442dc38b2261d", "shasum": "" }, "require": { @@ -4421,13 +4476,14 @@ }, "require-dev": { "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/error-handler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/stopwatch": "^6.4|^7.0" + "symfony/stopwatch": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4455,7 +4511,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v7.3.0" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.0" }, "funding": [ { @@ -4466,12 +4522,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-22T09:11:45+00:00" + "time": "2025-10-28T09:38:46+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -4551,16 +4611,16 @@ }, { "name": "symfony/filesystem", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" + "reference": "d551b38811096d0be9c4691d406991b47c0c630a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", - "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d551b38811096d0be9c4691d406991b47c0c630a", + "reference": "d551b38811096d0be9c4691d406991b47c0c630a", "shasum": "" }, "require": { @@ -4569,7 +4629,7 @@ "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^6.4|^7.0" + "symfony/process": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4597,7 +4657,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v7.3.0" + "source": "https://github.com/symfony/filesystem/tree/v7.4.0" }, "funding": [ { @@ -4608,32 +4668,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-10-25T15:15:23+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/finder", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d" + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/ec2344cf77a48253bbca6939aa3d2477773ea63d", - "reference": "ec2344cf77a48253bbca6939aa3d2477773ea63d", + "url": "https://api.github.com/repos/symfony/finder/zipball/340b9ed7320570f319028a2cbec46d40535e94bd", + "reference": "340b9ed7320570f319028a2cbec46d40535e94bd", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "symfony/filesystem": "^6.4|^7.0" + "symfony/filesystem": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4661,7 +4725,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.3.0" + "source": "https://github.com/symfony/finder/tree/v7.4.0" }, "funding": [ { @@ -4672,32 +4736,35 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-12-30T19:00:26+00:00" + "time": "2025-11-05T05:42:40+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.3.0", + "version": "v7.4.1", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "4236baf01609667d53b20371486228231eb135fd" + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/4236baf01609667d53b20371486228231eb135fd", - "reference": "4236baf01609667d53b20371486228231eb135fd", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bd1af1e425811d6f077db240c3a588bdb405cd27", + "reference": "bd1af1e425811d6f077db240c3a588bdb405cd27", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3.0", - "symfony/polyfill-mbstring": "~1.1", - "symfony/polyfill-php83": "^1.27" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" }, "conflict": { "doctrine/dbal": "<3.6", @@ -4706,13 +4773,13 @@ "require-dev": { "doctrine/dbal": "^3.6|^4", "predis/predis": "^1.1|^2.0", - "symfony/cache": "^6.4.12|^7.1.5", - "symfony/clock": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/rate-limiter": "^6.4|^7.0" + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -4740,7 +4807,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.3.0" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.1" }, "funding": [ { @@ -4751,34 +4818,38 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-12T14:48:23+00:00" + "time": "2025-12-07T11:13:10+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.3.0", + "version": "v7.4.2", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "ac7b8e163e8c83dce3abcc055a502d4486051a9f" + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/ac7b8e163e8c83dce3abcc055a502d4486051a9f", - "reference": "ac7b8e163e8c83dce3abcc055a502d4486051a9f", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/f6e6f0a5fa8763f75a504b930163785fb6dd055f", + "reference": "f6e6f0a5fa8763f75a504b930163785fb6dd055f", "shasum": "" }, "require": { "php": ">=8.2", "psr/log": "^1|^2|^3", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/error-handler": "^6.4|^7.0", - "symfony/event-dispatcher": "^7.3", - "symfony/http-foundation": "^7.3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", "symfony/polyfill-ctype": "^1.8" }, "conflict": { @@ -4788,6 +4859,7 @@ "symfony/console": "<6.4", "symfony/dependency-injection": "<6.4", "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", "symfony/form": "<6.4", "symfony/http-client": "<6.4", "symfony/http-client-contracts": "<2.5", @@ -4805,27 +4877,27 @@ }, "require-dev": { "psr/cache": "^1.0|^2.0|^3.0", - "symfony/browser-kit": "^6.4|^7.0", - "symfony/clock": "^6.4|^7.0", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/css-selector": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/dom-crawler": "^6.4|^7.0", - "symfony/expression-language": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^7.1", - "symfony/routing": "^6.4|^7.0", - "symfony/serializer": "^7.1", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/translation": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", - "symfony/uid": "^6.4|^7.0", - "symfony/validator": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", - "symfony/var-exporter": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "type": "library", @@ -4854,7 +4926,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.3.0" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.2" }, "funding": [ { @@ -4865,29 +4937,34 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-29T07:47:32+00:00" + "time": "2025-12-08T07:43:37+00:00" }, { "name": "symfony/mime", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9" + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", - "reference": "0e7b19b2f399c31df0cdbe5d8cbf53f02f6cfcd9", + "url": "https://api.github.com/repos/symfony/mime/zipball/bdb02729471be5d047a3ac4a69068748f1a6be7a", + "reference": "bdb02729471be5d047a3ac4a69068748f1a6be7a", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-intl-idn": "^1.10", "symfony/polyfill-mbstring": "^1.0" }, @@ -4902,11 +4979,11 @@ "egulias/email-validator": "^2.1.10|^3.1|^4", "league/html-to-markdown": "^5.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4.3|^7.0.3" + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" }, "type": "library", "autoload": { @@ -4938,7 +5015,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.3.0" + "source": "https://github.com/symfony/mime/tree/v7.4.0" }, "funding": [ { @@ -4949,16 +5026,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-02-19T08:51:26+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { "name": "symfony/polyfill-ctype", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", @@ -5017,7 +5098,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0" }, "funding": [ { @@ -5028,6 +5109,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5037,16 +5122,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/380872130d3a5dd3ace2f4010d95125fde5d5c70", + "reference": "380872130d3a5dd3ace2f4010d95125fde5d5c70", "shasum": "" }, "require": { @@ -5095,7 +5180,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.33.0" }, "funding": [ { @@ -5106,16 +5191,20 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-06-27T09:58:17+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", @@ -5178,7 +5267,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.33.0" }, "funding": [ { @@ -5189,6 +5278,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5198,7 +5291,7 @@ }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", @@ -5259,7 +5352,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0" }, "funding": [ { @@ -5270,6 +5363,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5279,7 +5376,7 @@ }, { "name": "symfony/polyfill-mbstring", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", @@ -5340,7 +5437,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0" }, "funding": [ { @@ -5351,6 +5448,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5360,7 +5461,7 @@ }, { "name": "symfony/polyfill-php80", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", @@ -5420,7 +5521,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -5431,6 +5532,10 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" @@ -5440,16 +5545,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.32.0", + "version": "v1.33.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", - "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/17f6f9a6b1735c0f163024d959f700cfbc5155e5", + "reference": "17f6f9a6b1735c0f163024d959f700cfbc5155e5", "shasum": "" }, "require": { @@ -5496,7 +5601,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.32.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.33.0" }, "funding": [ { @@ -5507,25 +5612,189 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-07-08T02:45:35+00:00" }, { - "name": "symfony/service-contracts", - "version": "v3.6.0", + "name": "symfony/polyfill-php84", + "version": "v1.33.0", "source": { "type": "git", - "url": "https://github.com/symfony/service-contracts.git", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4" + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/f021b05a130d35510bd6b25fe9053c2a8a15d5d4", - "reference": "f021b05a130d35510bd6b25fe9053c2a8a15d5d4", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/d8ced4d875142b6a7426000426b8abc631d6b191", + "reference": "d8ced4d875142b6a7426000426b8abc631d6b191", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-24T13:30:11+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.33.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "reference": "d4e5fcd4ab3d998ab16c0db48e6cbb9a01993f91", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.33.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2025-06-23T16:12:55+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43", + "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43", "shasum": "" }, "require": { @@ -5579,7 +5848,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/service-contracts/tree/v3.6.1" }, "funding": [ { @@ -5590,31 +5859,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-25T09:37:31+00:00" + "time": "2025-07-15T11:30:57+00:00" }, { "name": "symfony/string", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125" + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/f3570b8c61ca887a9e2938e85cb6458515d2b125", - "reference": "f3570b8c61ca887a9e2938e85cb6458515d2b125", + "url": "https://api.github.com/repos/symfony/string/zipball/d50e862cb0a0e0886f73ca1f31b865efbb795003", + "reference": "d50e862cb0a0e0886f73ca1f31b865efbb795003", "shasum": "" }, "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-grapheme": "~1.33", "symfony/polyfill-intl-normalizer": "~1.0", "symfony/polyfill-mbstring": "~1.0" }, @@ -5622,12 +5896,11 @@ "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/emoji": "^7.1", - "symfony/error-handler": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3.0", - "symfony/var-exporter": "^6.4|^7.0" + "symfony/var-exporter": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5666,7 +5939,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v7.3.0" + "source": "https://github.com/symfony/string/tree/v7.4.0" }, "funding": [ { @@ -5677,38 +5950,36 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-20T20:19:01+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "4aba29076a29a3aa667e09b791e5f868973a8667" + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/4aba29076a29a3aa667e09b791e5f868973a8667", - "reference": "4aba29076a29a3aa667e09b791e5f868973a8667", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/symfony/translation/zipball/2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "reference": "2d01ca0da3f092f91eeedb46f24aa30d2fca8f68", + "shasum": "" }, "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", - "symfony/translation-contracts": "^2.5|^3.0" + "symfony/translation-contracts": "^2.5.3|^3.3" }, "conflict": { "nikic/php-parser": "<5.0", @@ -5727,17 +5998,17 @@ "require-dev": { "nikic/php-parser": "^5.0", "psr/log": "^1|^2|^3", - "symfony/config": "^6.4|^7.0", - "symfony/console": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/finder": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", "symfony/http-client-contracts": "^2.5|^3.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/intl": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", "symfony/polyfill-intl-icu": "^1.21", - "symfony/routing": "^6.4|^7.0", + "symfony/routing": "^6.4|^7.0|^8.0", "symfony/service-contracts": "^2.5|^3", - "symfony/yaml": "^6.4|^7.0" + "symfony/yaml": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -5768,7 +6039,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v7.3.0" + "source": "https://github.com/symfony/translation/tree/v7.4.0" }, "funding": [ { @@ -5779,25 +6050,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-29T07:19:49+00:00" + "time": "2025-11-27T13:27:24+00:00" }, { "name": "symfony/translation-contracts", - "version": "v3.6.0", + "version": "v3.6.1", "source": { "type": "git", "url": "https://github.com/symfony/translation-contracts.git", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d" + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/df210c7a2573f1913b2d17cc95f90f53a73d8f7d", - "reference": "df210c7a2573f1913b2d17cc95f90f53a73d8f7d", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { @@ -5846,7 +6121,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/translation-contracts/tree/v3.6.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -5857,25 +6132,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-09-27T08:32:26+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e" + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/548f6760c54197b1084e1e5c71f6d9d523f2f78e", - "reference": "548f6760c54197b1084e1e5c71f6d9d523f2f78e", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", "shasum": "" }, "require": { @@ -5887,11 +6166,10 @@ "symfony/console": "<6.4" }, "require-dev": { - "ext-iconv": "*", - "symfony/console": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "twig/twig": "^3.12" }, "bin": [ @@ -5930,7 +6208,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.3.0" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" }, "funding": [ { @@ -5941,25 +6219,29 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-04-27T18:39:23+00:00" + "time": "2025-10-27T20:36:44+00:00" }, { "name": "symfony/var-exporter", - "version": "v7.3.0", + "version": "v7.4.0", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c" + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/c9a1168891b5aaadfd6332ef44393330b3498c4c", - "reference": "c9a1168891b5aaadfd6332ef44393330b3498c4c", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/03a60f169c79a28513a78c967316fbc8bf17816f", + "reference": "03a60f169c79a28513a78c967316fbc8bf17816f", "shasum": "" }, "require": { @@ -5967,9 +6249,9 @@ "symfony/deprecation-contracts": "^2.5|^3" }, "require-dev": { - "symfony/property-access": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0" + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" }, "type": "library", "autoload": { @@ -6007,7 +6289,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v7.3.0" + "source": "https://github.com/symfony/var-exporter/tree/v7.4.0" }, "funding": [ { @@ -6018,12 +6300,16 @@ "url": "https://github.com/fabpot", "type": "github" }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, { "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2025-05-15T09:04:05+00:00" + "time": "2025-09-11T10:15:23+00:00" }, { "name": "topthink/think-container", @@ -6358,22 +6644,22 @@ }, { "name": "webman/console", - "version": "v2.1.8", + "version": "v2.1.10", "source": { "type": "git", "url": "https://github.com/webman-php/console.git", - "reference": "2d4ce527810f91e1b4f583ec90eae24a0334ab10" + "reference": "6b24bb77d04588c7c3b235087073e69ddfdaa773" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/console/zipball/2d4ce527810f91e1b4f583ec90eae24a0334ab10", - "reference": "2d4ce527810f91e1b4f583ec90eae24a0334ab10", + "url": "https://api.github.com/repos/webman-php/console/zipball/6b24bb77d04588c7c3b235087073e69ddfdaa773", + "reference": "6b24bb77d04588c7c3b235087073e69ddfdaa773", "shasum": "" }, "require": { "doctrine/inflector": "^2.0", "php": ">=8.1", - "symfony/console": ">=6.0" + "symfony/console": "^6.0 || ^7.0" }, "require-dev": { "workerman/webman": "^2.1" @@ -6408,20 +6694,20 @@ "source": "https://github.com/webman-php/console", "wiki": "http://www.workerman.net/doc/webman" }, - "time": "2025-06-02T00:42:16+00:00" + "time": "2025-12-08T08:23:12+00:00" }, { "name": "webman/database", - "version": "v2.1.6", + "version": "v2.1.7", "source": { "type": "git", "url": "https://github.com/webman-php/database.git", - "reference": "943c5a0f92d8959d9160c2ee3925112b15c308c8" + "reference": "81a8a834cc8db5a5dbb46fe0073a9d5ac73848f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/database/zipball/943c5a0f92d8959d9160c2ee3925112b15c308c8", - "reference": "943c5a0f92d8959d9160c2ee3925112b15c308c8", + "url": "https://api.github.com/repos/webman-php/database/zipball/81a8a834cc8db5a5dbb46fe0073a9d5ac73848f6", + "reference": "81a8a834cc8db5a5dbb46fe0073a9d5ac73848f6", "shasum": "" }, "require": { @@ -6444,9 +6730,9 @@ "description": "Webman database", "support": { "issues": "https://github.com/webman-php/database/issues", - "source": "https://github.com/webman-php/database/tree/v2.1.6" + "source": "https://github.com/webman-php/database/tree/v2.1.7" }, - "time": "2025-04-04T13:23:25+00:00" + "time": "2025-11-27T08:09:25+00:00" }, { "name": "webman/gateway-worker", @@ -6481,6 +6767,40 @@ }, "time": "2024-04-30T01:05:42+00:00" }, + { + "name": "webman/log", + "version": "v2.1.3", + "source": { + "type": "git", + "url": "https://github.com/webman-php/log.git", + "reference": "14e3f3e64e87783952417a68cf5205c1293aba69" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webman-php/log/zipball/14e3f3e64e87783952417a68cf5205c1293aba69", + "reference": "14e3f3e64e87783952417a68cf5205c1293aba69", + "shasum": "" + }, + "require": { + "workerman/webman-framework": "^2.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webman\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Webman plugin webman/log", + "support": { + "issues": "https://github.com/webman-php/log/issues", + "source": "https://github.com/webman-php/log/tree/v2.1.3" + }, + "time": "2025-02-26T11:43:36+00:00" + }, { "name": "webman/redis", "version": "v2.1.3", @@ -6519,16 +6839,16 @@ }, { "name": "webman/redis-queue", - "version": "v2.1.0", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/webman-php/redis-queue.git", - "reference": "12e1c9915ec64c91c0c9c255606bd0c7de27334c" + "reference": "ff4791e21f3c324a47e21da7b6f2dae5a7311dcb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webman-php/redis-queue/zipball/12e1c9915ec64c91c0c9c255606bd0c7de27334c", - "reference": "12e1c9915ec64c91c0c9c255606bd0c7de27334c", + "url": "https://api.github.com/repos/webman-php/redis-queue/zipball/ff4791e21f3c324a47e21da7b6f2dae5a7311dcb", + "reference": "ff4791e21f3c324a47e21da7b6f2dae5a7311dcb", "shasum": "" }, "require": { @@ -6547,22 +6867,22 @@ "description": "Redis message queue plugin for webman.", "support": { "issues": "https://github.com/webman-php/redis-queue/issues", - "source": "https://github.com/webman-php/redis-queue/tree/v2.1.0" + "source": "https://github.com/webman-php/redis-queue/tree/v2.1.1" }, - "time": "2025-01-24T12:56:01+00:00" + "time": "2025-11-14T07:12:52+00:00" }, { "name": "workerman/coroutine", - "version": "v1.1.3", + "version": "v1.1.4", "source": { "type": "git", "url": "https://github.com/workerman-php/coroutine.git", - "reference": "df8fc428967d512a74a8a7d80355c1d40228c9fa" + "reference": "b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/df8fc428967d512a74a8a7d80355c1d40228c9fa", - "reference": "df8fc428967d512a74a8a7d80355c1d40228c9fa", + "url": "https://api.github.com/repos/workerman-php/coroutine/zipball/b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44", + "reference": "b0bebfa9d41b992ad0a835ddf2ee8fa5d58eca44", "shasum": "" }, "require": { @@ -6586,9 +6906,9 @@ "description": "Workerman coroutine", "support": { "issues": "https://github.com/workerman-php/coroutine/issues", - "source": "https://github.com/workerman-php/coroutine/tree/v1.1.3" + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.4" }, - "time": "2025-02-17T03:34:21+00:00" + "time": "2025-10-11T15:09:08+00:00" }, { "name": "workerman/crontab", @@ -6696,23 +7016,17 @@ }, { "name": "workerman/redis", - "version": "v2.0.4", + "version": "v2.0.5", "source": { "type": "git", "url": "https://github.com/walkor/redis.git", - "reference": "82b398c5bdd5d683a3458aaf645b159b7b39b84e" + "reference": "49627c1809eff1ef7175eb8ee7549234a1d67ec5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/walkor/redis/zipball/82b398c5bdd5d683a3458aaf645b159b7b39b84e", - "reference": "82b398c5bdd5d683a3458aaf645b159b7b39b84e", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "url": "https://api.github.com/repos/walkor/redis/zipball/49627c1809eff1ef7175eb8ee7549234a1d67ec5", + "reference": "49627c1809eff1ef7175eb8ee7549234a1d67ec5", + "shasum": "" }, "require": { "php": ">=7", @@ -6731,9 +7045,9 @@ "homepage": "http://www.workerman.net", "support": { "issues": "https://github.com/walkor/redis/issues", - "source": "https://github.com/walkor/redis/tree/v2.0.4" + "source": "https://github.com/walkor/redis/tree/v2.0.5" }, - "time": "2025-04-05T07:45:12+00:00" + "time": "2025-04-07T01:58:58+00:00" }, { "name": "workerman/redis-queue", @@ -6747,13 +7061,7 @@ "type": "zip", "url": "https://api.github.com/repos/walkor/redis-queue/zipball/75dbf7ed2ea228c45dc0df82c0fea35879b715d0", "reference": "75dbf7ed2ea228c45dc0df82c0fea35879b715d0", - "shasum": "", - "mirrors": [ - { - "url": "https://mirrors.aliyun.com/composer/dists/%package%/%reference%.%type%", - "preferred": true - } - ] + "shasum": "" }, "require": { "php": ">=7.0", @@ -6780,16 +7088,16 @@ }, { "name": "workerman/webman-framework", - "version": "v2.1.2", + "version": "v2.1.4", "source": { "type": "git", "url": "https://github.com/walkor/webman-framework.git", - "reference": "f803bd867f07bb0929faef060b59a19a44186bfc" + "reference": "f19d1f9e47cc50210555f0b63db5ae1dd584f793" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/walkor/webman-framework/zipball/f803bd867f07bb0929faef060b59a19a44186bfc", - "reference": "f803bd867f07bb0929faef060b59a19a44186bfc", + "url": "https://api.github.com/repos/walkor/webman-framework/zipball/f19d1f9e47cc50210555f0b63db5ae1dd584f793", + "reference": "f19d1f9e47cc50210555f0b63db5ae1dd584f793", "shasum": "" }, "require": { @@ -6797,7 +7105,7 @@ "nikic/fast-route": "^1.3", "php": ">=8.1", "psr/container": ">=1.0", - "psr/log": "^3.0", + "psr/log": "^2.0 || ^3.0", "workerman/workerman": "^5.1 || dev-master" }, "suggest": { @@ -6842,20 +7150,20 @@ "source": "https://github.com/walkor/webman-framework", "wiki": "https://doc.workerman.net/" }, - "time": "2025-03-10T11:52:22+00:00" + "time": "2025-11-10T06:59:23+00:00" }, { "name": "workerman/workerman", - "version": "v5.1.3", + "version": "v5.1.6", "source": { "type": "git", "url": "https://github.com/walkor/workerman.git", - "reference": "371f3a5decb28f1bd3464ae26d47ea1a4cf0a3c5" + "reference": "9b83bdf9fd0eaff9419a240b47da870447583112" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/walkor/workerman/zipball/371f3a5decb28f1bd3464ae26d47ea1a4cf0a3c5", - "reference": "371f3a5decb28f1bd3464ae26d47ea1a4cf0a3c5", + "url": "https://api.github.com/repos/walkor/workerman/zipball/9b83bdf9fd0eaff9419a240b47da870447583112", + "reference": "9b83bdf9fd0eaff9419a240b47da870447583112", "shasum": "" }, "require": { @@ -6868,9 +7176,9 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.0", - "mockery/mockery": "^1.6", + "mockery/mockery": "2.0.x-dev", "pestphp/pest": "2.x-dev", - "phpstan/phpstan": "1.11.x-dev" + "phpstan/phpstan": "2.1.x-dev" }, "suggest": { "ext-event": "For better performance. " @@ -6918,28 +7226,31 @@ "type": "patreon" } ], - "time": "2025-06-12T13:34:04+00:00" + "time": "2025-11-12T01:36:20+00:00" }, { "name": "zoujingli/ip2region", - "version": "v3.0.0", + "version": "v3.0.8", "source": { "type": "git", "url": "https://github.com/zoujingli/ip2region.git", - "reference": "fd31d7a023f28f4e2f3e63f46f1fd51f8b2672f6" + "reference": "4ab4283f4b4ea4222da6cb97fbf7f7167f1ebb53" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/zoujingli/ip2region/zipball/fd31d7a023f28f4e2f3e63f46f1fd51f8b2672f6", - "reference": "fd31d7a023f28f4e2f3e63f46f1fd51f8b2672f6", + "url": "https://api.github.com/repos/zoujingli/ip2region/zipball/4ab4283f4b4ea4222da6cb97fbf7f7167f1ebb53", + "reference": "4ab4283f4b4ea4222da6cb97fbf7f7167f1ebb53", "shasum": "" }, "require": { - "php": ">=5.4" + "php": ">=7.1.0" }, "suggest": { "ext-opcache": "启用 OPcache 以获得更好的性能" }, + "bin": [ + "bin/ip2down" + ], "type": "library", "extra": { "branch-alias": { @@ -6948,10 +7259,13 @@ }, "autoload": { "files": [ - "ip2region.php" + "src/common.php" ], + "psr-4": { + "ip2region\\": "src/ip2region/" + }, "classmap": [ - "src/" + "src/Ip2Region.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -6965,30 +7279,28 @@ "homepage": "https://thinkadmin.top" } ], - "description": "Ip2Region v3.0 for PHP - 企业级 IP 地理位置查询库,支持 IPv4/IPv6,分片文件管理,智能缓存,零依赖", + "description": "ip2region v3.0 for PHP - 企业级 IP 地理位置查询库,支持 IPv4 和 IPv6,多种缓存策略,零依赖,开箱即用", "homepage": "https://github.com/zoujingli/Ip2Region", "keywords": [ "IP", - "IP定位", "Ip2Region", - "geoip", + "composer", "geolocation", "ipv4", "ipv6", "location", + "php", "企业级", - "分片数据库", - "压缩", - "地理位置", - "地理位置查询", - "缓存", + "智能加载", + "缓存策略", + "零依赖", "高性能" ], "support": { "issues": "https://github.com/zoujingli/ip2region/issues", - "source": "https://github.com/zoujingli/ip2region/tree/v3.0.0" + "source": "https://github.com/zoujingli/ip2region/tree/v3.0.8" }, - "time": "2025-09-15T12:27:22+00:00" + "time": "2025-11-20T11:02:33+00:00" } ], "packages-dev": [], diff --git a/config/plugin/webman/log/app.php b/config/plugin/webman/log/app.php new file mode 100644 index 0000000..6217553 --- /dev/null +++ b/config/plugin/webman/log/app.php @@ -0,0 +1,19 @@ + true, + 'exception' => [ + // 是否记录异常到日志 + 'enable' => true, + // 不会记录到日志的异常类 + 'dontReport' => [ + support\exception\BusinessException::class + ] + ], + 'dontReport' => [ + 'app' => [], + 'controller' => [], + 'action' => [], + 'path' => [] + ], + 'channel' => 'default' // 日志通道(在config/log.php里配置,默认是default) +]; diff --git a/config/plugin/webman/log/middleware.php b/config/plugin/webman/log/middleware.php new file mode 100644 index 0000000..7c7b0a9 --- /dev/null +++ b/config/plugin/webman/log/middleware.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Log\Middleware; + +return [ + '@' => [ + Middleware::class + ] +]; From b60df7f5cfeae879108e60eb8cabb1122937800a Mon Sep 17 00:00:00 2001 From: mzl Date: Wed, 10 Dec 2025 20:26:21 +0800 Subject: [PATCH 02/14] ts --- app/command/InquiryAssignInquiryCommand.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/command/InquiryAssignInquiryCommand.php b/app/command/InquiryAssignInquiryCommand.php index adb8309..6ce6b2f 100644 --- a/app/command/InquiryAssignInquiryCommand.php +++ b/app/command/InquiryAssignInquiryCommand.php @@ -52,7 +52,7 @@ class InquiryAssignInquiryCommand extends Command /** * 每次脚本最多分配多少条 */ - protected const BATCH_LIMIT = 10; + protected const BATCH_LIMIT = 300; protected function execute(InputInterface $input, OutputInterface $output): int { @@ -73,7 +73,7 @@ class InquiryAssignInquiryCommand extends Command ->where('channel', 10) ->where('status', 0) ->where('recipient', 0) - ->where('id', '>', 34426) + ->where('id', '>', 34847) ->orderBy('id') ->limit(static::BATCH_LIMIT) ->get(); From 38deea3b0d65793ed7653626734aafe0ec9c83fd Mon Sep 17 00:00:00 2001 From: mzl Date: Thu, 11 Dec 2025 15:07:55 +0800 Subject: [PATCH 03/14] Merge branch 'develop' --- app/command/InquiryReissueCommand.php | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 app/command/InquiryReissueCommand.php diff --git a/app/command/InquiryReissueCommand.php b/app/command/InquiryReissueCommand.php new file mode 100644 index 0000000..794d419 --- /dev/null +++ b/app/command/InquiryReissueCommand.php @@ -0,0 +1,43 @@ +where('sync_crm', 0) + ->where('id', '>', 33785) + ->orderBy('id') + ->get(); + + foreach ($inquiries as $row) { + $inquiryId = (int)$row->id; + + var_dump('补发ID:'.$inquiryId); + + sleep(1); + + $res = [ + 'id' => $inquiryId + ]; + RedisQueueRedis::send('inquiry-to-crm-new', $res); + } + + return self::SUCCESS; + } +} \ No newline at end of file From 85a79193a663efbcb2269a4d4f61aa2efd802e70 Mon Sep 17 00:00:00 2001 From: mzl Date: Thu, 11 Dec 2025 15:23:02 +0800 Subject: [PATCH 04/14] ts --- app/command/InquiryReissueCommand.php | 2 +- app/queue/redis/InquiryToCrmNew.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/command/InquiryReissueCommand.php b/app/command/InquiryReissueCommand.php index 794d419..1ef8da3 100644 --- a/app/command/InquiryReissueCommand.php +++ b/app/command/InquiryReissueCommand.php @@ -14,7 +14,7 @@ use Webman\RedisQueue\Redis as RedisQueueRedis; */ class InquiryReissueCommand extends Command { - protected static $defaultName = 'iq_bf'; + protected static $defaultName = 'iq-bf'; protected static $defaultDescription = ''; protected function execute(InputInterface $input, OutputInterface $output): int diff --git a/app/queue/redis/InquiryToCrmNew.php b/app/queue/redis/InquiryToCrmNew.php index 2ad666f..c3d0abf 100644 --- a/app/queue/redis/InquiryToCrmNew.php +++ b/app/queue/redis/InquiryToCrmNew.php @@ -39,7 +39,7 @@ class InquiryToCrmNew implements Consumer 发过 没分 更新 (要同步)7 发过 分过 更新 (要同步)8 */ - +var_dump('询单同步开始:'.$data['id']); try { // 获取询盘信息 $info = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('id', $data['id'])->first(); @@ -57,6 +57,7 @@ class InquiryToCrmNew implements Consumer // 更改库信息 $this->syncCustomer($info); } catch (\Throwable $th) { + var_dump('询单同步失败:'.$th->getMessage().' | '.$th->getLine().' | '.$th->getFile()); throw $th; } } From f0af3a0cdb2d3ddc6045ec8496176b61077c0175 Mon Sep 17 00:00:00 2001 From: mzl Date: Thu, 11 Dec 2025 15:32:36 +0800 Subject: [PATCH 05/14] ts --- app/queue/redis/InquiryToCrmNew.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/queue/redis/InquiryToCrmNew.php b/app/queue/redis/InquiryToCrmNew.php index c3d0abf..4441610 100644 --- a/app/queue/redis/InquiryToCrmNew.php +++ b/app/queue/redis/InquiryToCrmNew.php @@ -39,7 +39,7 @@ class InquiryToCrmNew implements Consumer 发过 没分 更新 (要同步)7 发过 分过 更新 (要同步)8 */ -var_dump('询单同步开始:'.$data['id']); + // var_dump('询单同步开始:'.$data['id']); try { // 获取询盘信息 $info = InquiryLog::with(['channelInfo', 'countryInfo', 'enter'])->where('id', $data['id'])->first(); @@ -57,7 +57,7 @@ var_dump('询单同步开始:'.$data['id']); // 更改库信息 $this->syncCustomer($info); } catch (\Throwable $th) { - var_dump('询单同步失败:'.$th->getMessage().' | '.$th->getLine().' | '.$th->getFile()); + flg('询单同步失败:'.$th->getMessage().' | '.$th->getLine().' | '.$th->getFile(), false); throw $th; } } @@ -133,6 +133,11 @@ var_dump('询单同步开始:'.$data['id']); if (!$crmCustomerInfo->update($udata)) { throw new BusinessException('同步失败,更新客户线索失败'); } + + $info->sync_crm = 1; + if (!$info->save()) { + throw new BusinessException('标记询盘同步状态失败'); + } } else { $crmCustomer = new CrmCustomer(); $crmCustomer->sn = $info->order_no; From eea13a2a587ae07483a5d11ab622f7611a16d0b0 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 12 Dec 2025 17:52:20 +0800 Subject: [PATCH 06/14] Merge branch 'develop' # Conflicts: # app/command/InquiryImportFbCommand.php # app/inquiry/utils/FbLeadHeaderMapper.php --- app/command/InquiryImportFbCommand.php | 3 + app/inquiry/service/FbFileImportService.php | 201 ++++++++++++++++ app/inquiry/service/FbLeadSyncService.php | 240 +++++++++++++++++++ app/inquiry/service/InquiryAssignService.php | 167 +++++++++++++ app/inquiry/utils/FbLeadHeaderMapper.php | 128 +++++++--- app/process/InquiryAutoProcess.php | 97 ++++++++ app/queue/redis/PermAgt.php | 4 + config/inquiry_channel.php | 69 ++++++ config/process.php | 8 + 9 files changed, 882 insertions(+), 35 deletions(-) create mode 100644 app/inquiry/service/FbFileImportService.php create mode 100644 app/inquiry/service/FbLeadSyncService.php create mode 100644 app/inquiry/service/InquiryAssignService.php create mode 100644 app/process/InquiryAutoProcess.php create mode 100644 config/inquiry_channel.php diff --git a/app/command/InquiryImportFbCommand.php b/app/command/InquiryImportFbCommand.php index 3c9a3c0..e90884c 100644 --- a/app/command/InquiryImportFbCommand.php +++ b/app/command/InquiryImportFbCommand.php @@ -11,6 +11,9 @@ use PhpOffice\PhpSpreadsheet\IOFactory; use app\inquiry\utils\FbLeadHeaderMapper; use support\exception\BusinessException; +/** + * 从Excel文件导入fb_leads + */ class InquiryImportFbCommand extends Command { protected static $defaultName = 'iq-fb'; diff --git a/app/inquiry/service/FbFileImportService.php b/app/inquiry/service/FbFileImportService.php new file mode 100644 index 0000000..c598158 --- /dev/null +++ b/app/inquiry/service/FbFileImportService.php @@ -0,0 +1,201 @@ +importOneFile($filePath, $channelId); + $count++; + + // 导入成功后,把文件重命名为 .xlsx.done,避免下次重复导入 + $newPath = $filePath . '.done'; + if (!@rename($filePath, $newPath)) { + Log::warning("FbFileImportService: 文件导入成功,但重命名失败:{$filePath} -> {$newPath}"); + } else { + Log::info("FbFileImportService: 文件导入并标记完成:{$newPath}(行数:{$rows})"); + } + } + + return $count; + } + + /** + * 导入单个 Excel 到 fb_leads + */ + public function importOneFile(string $filePath, int $channelId): int + { + if (!is_file($filePath)) { + throw new BusinessException("Excel 文件不存在:{$filePath}"); + } + + $spreadsheet = IOFactory::load($filePath); + $sheet = $spreadsheet->getActiveSheet(); + $rows = $sheet->toArray(null, true, true, true); + + if (empty($rows)) { + throw new BusinessException('Excel 内容为空:' . $filePath); + } + + // 表头 + $headerRow = array_shift($rows); + + $mapper = new FbLeadHeaderMapper(); + $res = $mapper->buildColumnFieldMap($headerRow); + $colToField = $res['colToField']; + $unknownCols = $res['unknownCols']; + + $insertData = []; + $now = time(); + $sourceFile = basename($filePath); + + foreach ($rows as $row) { + $item = []; + + foreach ($colToField as $colIndex => $field) { + $raw = $row[$colIndex] ?? null; + + switch ($field) { + case 'is_organic': + $item['is_organic'] = $this->normalizeIsOrganic($raw); + break; + case 'fb_created_time': + // ✅ 方案B:写入 DATETIME 字符串 + $item['fb_created_time'] = $this->normalizeCreatedTime($raw); + break; + default: + // 小增强:字符串统一 trim(避免 " xxx ") + if (is_string($raw)) { + $raw = trim($raw); + } + $item[$field] = $raw; + break; + } + } + + // 未知列收集到 extra_data(宽松模式) + $extra = []; + foreach ($unknownCols as $colIndex => $rawHeader) { + $val = $row[$colIndex] ?? null; + if ($val === null || $val === '') continue; + $extra[$rawHeader] = is_string($val) ? trim($val) : $val; + } + $item['extra_data'] = $extra ? json_encode($extra, JSON_UNESCAPED_UNICODE) : null; + + $item['source_file'] = $sourceFile; + $item['channel'] = $channelId; + + $item['created_at'] = $now; + $item['updated_at'] = $now; + $item['sync_status'] = 0; + + $insertData[] = $item; + } + + if ($insertData) { + Db::table('fb_leads')->insert($insertData); + } + + return count($insertData); + } + + /** + * 规范化 is_organic 字段为 0/1 + */ + protected function normalizeIsOrganic($raw): int + { + if (is_bool($raw)) { + return $raw ? 1 : 0; + } + $str = strtolower(trim((string)$raw)); + if ($str === '') { + return 0; + } + return in_array($str, ['1', 'true', 'yes', 'organic'], true) ? 1 : 0; + } + + /** + * ✅ 方案B:把 FB 创建时间统一转成 MySQL DATETIME(Y-m-d H:i:s) + * + * 支持: + * - "10/13/25" / "12/8/25" + * - "2025-12-08 13:22:11" + * - Excel 日期序列(数字) + * + * 解析失败:返回 null(让 DB 用默认值/允许为空) + * 注意:如果你的 fb_created_time 列是 NOT NULL 且无默认值,请把 null 改成 '1970-01-01 00:00:00' + */ + protected function normalizeCreatedTime($raw): ?string + { + if ($raw === null || $raw === '') { + return null; + } + + // Excel 日期序列:数字 + if (is_numeric($raw)) { + $n = (float)$raw; + // Excel serial date -> unix timestamp + $ts = (int)round(($n - 25569) * 86400); + if ($ts > 0) { + return date('Y-m-d H:i:s', $ts); + } + return null; + } + + $s = trim((string)$raw); + if ($s === '') { + return null; + } + + // 1) strtotime 能解析很多格式 + $ts = strtotime($s); + if ($ts !== false) { + return date('Y-m-d H:i:s', $ts); + } + + // 2) 兼容 m/d/yy(如 10/13/25) + if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{2})$#', $s, $m)) { + $mm = (int)$m[1]; + $dd = (int)$m[2]; + $yy = (int)$m[3]; + $year = 2000 + $yy; // FB 导出通常是 20xx + $ts2 = mktime(0, 0, 0, $mm, $dd, $year); + return date('Y-m-d H:i:s', $ts2); + } + + // 3) 兜底:无法解析 + return null; + } +} diff --git a/app/inquiry/service/FbLeadSyncService.php b/app/inquiry/service/FbLeadSyncService.php new file mode 100644 index 0000000..3f7432d --- /dev/null +++ b/app/inquiry/service/FbLeadSyncService.php @@ -0,0 +1,240 @@ +where('sync_status', 0) + ->where('channel', $channelId) + ->get(); + + if ($leads->isEmpty()) { + return 0; + } + + $now = time(); + + // 转数组,后面循环更快 + $leadArrList = []; + foreach ($leads as $lead) { + $leadArrList[] = (array)$lead; + } + + // ① 收集本批手机号(去重) + $phoneList = $this->collectPhonesFromLeads($leadArrList); + + // ② 批量查出 inquiry_log 已存在手机号集合(索引加持) + $existingTelSet = $this->getExistingTelSet($phoneList); + + $insert = []; + $idsToMarkProcessed = []; // 插入成功 or 重复跳过,统一标记已处理 + $insertCount = 0; + + Db::transaction(function () use ( + $leadArrList, $existingTelSet, $enterUserId, $channelId, $now, + &$insert, &$idsToMarkProcessed, &$insertCount + ) { + foreach ($leadArrList as $lead) { + $leadId = (int)($lead['id'] ?? 0); + + $tel = trim((string)($lead['phone'] ?? '')); + + // 如果手机号存在于 inquiry_log,跳过同步(但要标记 fb_leads 已处理) + if ($tel !== '' && isset($existingTelSet[$tel])) { + if ($leadId > 0) { + $idsToMarkProcessed[] = $leadId; + } + continue; + } + + $insert[] = $this->mapLeadToInquiry($lead, $now, $enterUserId, $channelId); + + if ($leadId > 0) { + $idsToMarkProcessed[] = $leadId; + } + } + + if (!empty($insert)) { + Db::table('inquiry_log')->insert($insert); + $insertCount = count($insert); + } + + if (!empty($idsToMarkProcessed)) { + Db::table('fb_leads') + ->whereIn('id', $idsToMarkProcessed) + ->update([ + 'sync_status' => 1, + 'updated_at' => $now, + ]); + } + }); + + return $insertCount; + } + + /** + * 从本批 leads 中收集手机号(tel),去重后返回 + */ + protected function collectPhonesFromLeads(array $leadArrList): array + { + $phones = []; + foreach ($leadArrList as $lead) { + $tel = trim((string)($lead['phone'] ?? '')); + if ($tel !== '') { + // 先用原值精确匹配(你后续要做归一化再改这里即可) + $phones[$tel] = true; + } + } + return array_keys($phones); + } + + /** + * 批量查出 inquiry_log 已存在手机号集合(返回 set:tel => true) + * - 自动分块 whereIn,避免 SQL 过长 + * - 建议给 inquiry_log.tel 加索引 idx_tel + */ + protected function getExistingTelSet(array $phoneList, int $chunkSize = 800): array + { + $phoneList = array_values(array_unique(array_filter(array_map('trim', $phoneList)))); + if (empty($phoneList)) { + return []; + } + + $existingTelSet = []; + + foreach (array_chunk($phoneList, $chunkSize) as $chunk) { + $rows = Db::table('inquiry_log') + ->select('tel') + ->where('tel', '<>', '') + ->whereIn('tel', $chunk) + ->get(); + + foreach ($rows as $r) { + $t = trim((string)($r->tel ?? '')); + if ($t !== '') { + $existingTelSet[$t] = true; + } + } + } + + return $existingTelSet; + } + + /** + * 映射一条 fb_leads -> inquiry_log + */ + protected function mapLeadToInquiry(array $lead, int $now, int $enterUserId, int $channelId): array + { + $email = (string)($lead['email'] ?? ''); + $tel = (string)($lead['phone'] ?? ''); + $wa = $tel; + + $countryId = PhoneCountryHelper::detectCountryId($tel ?: $wa); + + return [ + 'order_no' => $this->orderNum(), + 'enter_user_id' => $enterUserId, + 'recipient' => 0, + 'quality' => 2, + 'channel' => $channelId, // 👈 inquiry_log 里同样用这个渠道ID + + 'email' => $email, + 'tel' => $tel, + 'whatsapp' => $wa, + 'country' => $countryId ?: 0, + 'contacts' => (string)($lead['full_name'] ?? ''), + + 'content' => $this->buildContentHtml($lead), + + 'status' => 0, + 'r_time' => 0, + 't_time' => 0, + 'sync_crm' => 0, + + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + protected function orderNum(): string + { + return 'SN' . date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT); + } + + /** + * 富文本内容拼装 —— 基于你之前的版本 + */ + protected function buildContentHtml(array $lead): string + { + $e = function ($v) { + return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + }; + + $lines = []; + + if (!empty($lead['car_model'])) { + $lines[] = '

意向车型:' . $e($lead['car_model']) . '

'; + } + if (!empty($lead['car_quantity']) || !empty($lead['car_quantity_text'])) { + $qty = $lead['car_quantity'] ?? $lead['car_quantity_text'] ?? ''; + $lines[] = '

意向数量:' . $e($qty) . '

'; + } + if (!empty($lead['favorite_color'])) { + $lines[] = '

意向颜色:' . $e($lead['favorite_color']) . '

'; + } + if (!empty($lead['usage_type'])) { + $lines[] = '

用途:' . $e($lead['usage_type']) . '

'; + } + if (!empty($lead['seat_preference'])) { + $lines[] = '

座位数偏好:' . $e($lead['seat_preference']) . '

'; + } + if (!empty($lead['drive_type'])) { + $lines[] = '

驱动形式偏好:' . $e($lead['drive_type']) . '

'; + } + if (!empty($lead['extra_data'])) { + $extra = json_decode((string)$lead['extra_data'], true); + if (is_array($extra) && $extra) { + $lines[] = '
'; + $lines[] = '

补充问答信息:

'; + foreach ($extra as $k => $v) { + $lines[] = '

' . $e($k) . ':' . $e($v) . '

'; + } + } + } + + + $lines[] = '
'; + $lines[] = '

广告与表单信息:

'; + $lines[] = '

平台:' . $e($lead['platform'] ?? '') . '

'; + $lines[] = '

Campaign:' . $e($lead['campaign_name'] ?? '') . ' (#' . $e($lead['campaign_id'] ?? '') . ')

'; + $lines[] = '

Adset:' . $e($lead['adset_name'] ?? '') . ' (#' . $e($lead['adset_id'] ?? '') . ')

'; + $lines[] = '

Ad:' . $e($lead['ad_name'] ?? '') . ' (#' . $e($lead['ad_id'] ?? '') . ')

'; + $lines[] = '

Form:' . $e($lead['form_name'] ?? '') . ' (#' . $e($lead['form_id'] ?? '') . ')

'; + + $lines[] = '
'; + $lines[] = '

Meta 信息:

'; + $lines[] = '

Lead ID:' . $e($lead['fb_lead_id'] ?? '') . '

'; + $lines[] = '

FB 创建时间原始值:' . $e($lead['fb_created_time'] ?? '') . '

'; + + if (!empty($lead['inbox_url'])) { + $url = $e($lead['inbox_url']); + $lines[] = '

Inbox 链接:' . $url . '

'; + } + + return implode("\n", $lines); + } +} diff --git a/app/inquiry/service/InquiryAssignService.php b/app/inquiry/service/InquiryAssignService.php new file mode 100644 index 0000000..7fcdf10 --- /dev/null +++ b/app/inquiry/service/InquiryAssignService.php @@ -0,0 +1,167 @@ +normalizeUserIds($salesUserIds); + if (empty($defaultUserIds)) { + throw new BusinessException('分配失败:业务员列表为空'); + } + + $inquiries = Db::table('inquiry_log') + ->where('status', 0) + ->where('recipient', 0) + ->where('channel', $channelId) + ->orderBy('id') + ->limit($limit) + ->get(); + + if ($inquiries->isEmpty()) { + return 0; + } + + $now = time(); + $count = 0; + + foreach ($inquiries as $row) { + $inquiryId = (int)$row->id; + + // ✅ 关键:按规则选业务员池(命中规则就用规则池,否则用默认池) + [$poolUserIds, $rrKey] = $this->pickSalesPoolByRules( + (int)($row->country ?? 0), + $defaultUserIds, + $roundRobinKey, + $assignRules + ); + + // ✅ 用“对应池子自己的 rrKey”轮询拿业务员 + $recipientUserId = $this->nextRecipientId($poolUserIds, $rrKey); + + Db::transaction(function () use ($inquiryId, $recipientUserId, $now, &$count) { + $affected = Db::table('inquiry_log') + ->where('id', $inquiryId) + ->update([ + 'recipient' => $recipientUserId, + 'status' => 2, + 'r_time' => $now, + 't_time' => $now, + 'updated_at' => $now, + ]); + + if ($affected === 0) { + // 可能被其他进程抢先处理了,直接跳过 + return; + } + + $res = [ + 'id' => $inquiryId, + 'recipient' => $recipientUserId, + 'dispatched_at' => $now, + 'source' => 'fb_leads_inquiry', + ]; + + RedisQueueRedis::send('inquiry-to-crm-new', $res); + + $count++; + }); + } + + return $count; + } + + /** + * 轮询获取下一个业务员ID + */ + protected function nextRecipientId(array $userIds, string $groupKey): int + { + $userIds = $this->normalizeUserIds($userIds); + if (empty($userIds)) { + throw new BusinessException('轮询分配失败:业务员列表为空'); + } + + $count = count($userIds); + $key = 'inquiry:assign:round_robin:' . $groupKey; + + $val = Redis::incr($key); // 原子自增 + $index = ($val - 1) % $count; + + return $userIds[$index]; + } + + /** + * 按国家规则挑选“业务员池 + 轮询key” + * 命中 rules 则返回规则池,否则返回默认池 + * + * rules 示例: + * [ + * [ + * 'countries' => [1,2,3], + * 'sales_user_ids' => [63,64], + * 'round_robin_key' => 'fb_xxx_ru', + * ], + * ] + * + * @return array{0:int[], 1:string} [userIds, rrKey] + */ + protected function pickSalesPoolByRules( + int $countryId, + array $defaultUserIds, + string $defaultRrKey, + array $rules + ): array { + foreach ($rules as $rule) { + if (!is_array($rule)) continue; + + $countries = $rule['countries'] ?? []; + if (!is_array($countries) || empty($countries)) continue; + + if (!in_array($countryId, array_map('intval', $countries), true)) { + continue; + } + + $userIds = $this->normalizeUserIds($rule['sales_user_ids'] ?? []); + if (!empty($userIds)) { + // 规则池必须使用自己的 rrKey,否则会和默认池“抢指针” + $rrKey = (string)($rule['round_robin_key'] ?? ($defaultRrKey . '_rule_' . md5(json_encode($countries)))); + return [$userIds, $rrKey]; + } + } + + return [$this->normalizeUserIds($defaultUserIds), $defaultRrKey]; + } + + /** + * 统一清洗业务员ID数组:转 int、去重、过滤 <=0 + */ + protected function normalizeUserIds(array $ids): array + { + $ids = array_values(array_filter(array_map('intval', $ids), fn($v) => $v > 0)); + $ids = array_values(array_unique($ids)); + return $ids; + } +} diff --git a/app/inquiry/utils/FbLeadHeaderMapper.php b/app/inquiry/utils/FbLeadHeaderMapper.php index ed048ed..60b17dc 100644 --- a/app/inquiry/utils/FbLeadHeaderMapper.php +++ b/app/inquiry/utils/FbLeadHeaderMapper.php @@ -8,7 +8,7 @@ class FbLeadHeaderMapper { /** * Excel 原始表头 => DB 字段名 - * 注意:key 用的是“原始表头字符串”(包括下划线) + * 注意:这里的 key 会经过 normalizeHeader() 处理后再匹配 */ protected array $headerMap = [ // ===== 基础公共字段 ===== @@ -33,37 +33,53 @@ class FbLeadHeaderMapper 'inbox_url' => 'inbox_url', // ===== 车型相关:全部归到 car_model ===== - 'which_model_of_this_car_would_you_like_to_purchase?_' => 'car_model', - 'which_model_of_this_car_would_you_like_to_purchase?' => 'car_model', - 'which_car_model_would_you_like_to_purchase?' => 'car_model', - 'which_model_do_you_want_to_purchase?' => 'car_model', - 'which_model_would_you_like_to_purchase?' => 'car_model', - 'which_version_do_you_want_to_buy?' => 'car_model', // 这里也归到车型/版本字段 + 'which_model_of_this_car_would_you_like_to_purchase' => 'car_model', + 'which_car_model_would_you_like_to_purchase' => 'car_model', + 'which_model_do_you_want_to_purchase' => 'car_model', + 'which_model_would_you_like_to_purchase' => 'car_model', + 'which_version_do_you_want_to_buy' => 'car_model', // 这里也归到车型/版本字段 - // ===== 数量相关:全部归到 car_quantity ===== - 'how_many_cars_would_you_like_to_buy?_' => 'car_quantity', - 'how_many_cars_would_you_like_to_buy?' => 'car_quantity', - '_how_many_cars_would_you_like_to_buy?' => 'car_quantity', - 'how_many_cars_do_you_buy?' => 'car_quantity', - 'how_many_cars_do_you_want_to_purchase?' => 'car_quantity', - 'how_many_cars_do_you_want_to_buy?' => 'car_quantity', - 'what_quantity_do_you_want_to_buy?' => 'car_quantity', + // ===== 数量相关:建议归到 car_quantity_text(避免 1-2/3-10/10+)===== + // 你之前 car_quantity 用 int 时会截断,这里建议后续统一改成 car_quantity_text + 'how_many_cars_would_you_like_to_buy' => 'car_quantity_text', + '_how_many_cars_would_you_like_to_buy' => 'car_quantity_text', + 'how_many_cars_do_you_buy' => 'car_quantity_text', + 'how_many_cars_do_you_want_to_purchase' => 'car_quantity_text', + 'how_many_cars_do_you_want_to_buy' => 'car_quantity_text', + 'what_quantity_do_you_want_to_buy' => 'car_quantity_text', + 'what_quantity_do_you_wantv_to_buy' => 'car_quantity_text', // wantv 拼写兼容 // ===== 颜色相关:归到 favorite_color ===== - 'what_color_do_you_like?' => 'favorite_color', - 'which_color_would_you_like_to_purchase?' => 'favorite_color', + 'what_color_do_you_like' => 'favorite_color', + 'which_color_would_you_like_to_purchase' => 'favorite_color', + 'what_color_car_do_you_plan_to_buy' => 'favorite_color', // ===== 用途相关:归到 usage_type ===== - 'are_you_using_it_for_business_or_personal_use?' => 'usage_type', - 'business_use_or_personal_use?' => 'usage_type', - 'personal_use_or_business_use?' => 'usage_type', + 'are_you_using_it_for_business_or_personal_use' => 'usage_type', + 'business_use_or_personal_use' => 'usage_type', + 'personal_use_or_business_use' => 'usage_type', // ===== 座位数相关 ===== - 'do_you_want_to_buy_six_seats_or_seven_seats?' => 'seat_preference', + 'do_you_want_to_buy_six_seats_or_seven_seats' => 'seat_preference', + + // ===== AVATR 11 ===== + 'which_model_of_the_avatr_11_do_you_want_to_purchase' => 'car_model', + 'do_you_want_to_buy_a_four-wheel_drive_or_rear-wheel_drive_car' => 'drive_type', ]; /** - * 返回所有支持的 Excel 表头 + * 宽松模式下仍然强制要求存在的“核心 DB 字段” + * 你说的:联系人、电话、邮箱、国家、渠道 + * - country / channel 不是从 Excel 映射,而是你代码里根据电话识别/配置注入,所以这里不用要求 + */ + protected array $requiredDbFields = [ + 'full_name', + 'phone', + 'email', + ]; + + /** + * 返回所有支持的 Excel 表头(标准化后的 key) */ public function getSupportedHeaders(): array { @@ -76,12 +92,11 @@ class FbLeadHeaderMapper public function mapHeader(string $rawHeader): ?string { $key = $this->normalizeHeader($rawHeader); - return $this->headerMap[$key] ?? null; } /** - * 校验一整行表头: + * 旧的严格校验:保留(有时你可能还想用来排查某个表单的字段) * - 有任何不认识的表头,就抛 BusinessException */ public function validateHeaders(array $rawHeaders): void @@ -104,42 +119,74 @@ class FbLeadHeaderMapper if (!empty($unsupported)) { $msg = 'Excel 表头包含未兼容字段:' . implode('、', $unsupported) - . '。请检查这些字段是否需要加入映射配置。'; + . '。请检查这些字段是否需要加入映射配置。'; throw new BusinessException($msg); } } /** - * 根据第一行表头,构建 “列索引 => DB 字段” 映射 + * ✅ 宽松模式:根据第一行表头,构建映射 + * + * 返回结构: + * [ + * 'colToField' => [ 'A' => 'email', 'B' => 'phone', ... ], + * 'unknownCols' => [ 'C' => 'some_question?', ... ], // 未映射字段(后续可进富文本/extra_data) + * ] */ public function buildColumnFieldMap(array $rawHeaders): array { - // 先做一遍校验(发现问题直接抛异常) - $this->validateHeaders($rawHeaders); - - $colToField = []; + $colToField = []; + $unknownCols = []; foreach ($rawHeaders as $index => $header) { - $key = $this->normalizeHeader((string)$header); + $raw = (string)$header; + $key = $this->normalizeHeader($raw); + if ($key === '') { continue; } $field = $this->headerMap[$key] ?? null; + if ($field === null) { - continue; // 理论上不会到这儿,双保险 + // 宽松:记录未知列(保留原始表头用于展示) + $unknownCols[$index] = trim($raw) !== '' ? trim($raw) : $key; + continue; } $colToField[$index] = $field; } - return $colToField; + // 核心字段校验:缺少就终止(保证你说的“联系人/电话/邮箱没问题”) + $this->assertRequiredFields($colToField); + + return [ + 'colToField' => $colToField, + 'unknownCols' => $unknownCols, + ]; + } + + /** + * 核心字段校验:要求必须能映射出 requiredDbFields + */ + protected function assertRequiredFields(array $colToField): void + { + $fields = array_values($colToField); + foreach ($this->requiredDbFields as $req) { + if (!in_array($req, $fields, true)) { + throw new BusinessException("Excel 表头缺少必要字段映射:{$req}(请补充映射或别名)"); + } + } } /** * 表头标准化: - * 目前只去 BOM + trim,保持和 headerMap 中 key 完全一致 - * 如果后面再出现“有空格/大小写”的版本,我们可以在这里做 strtolower / 替换空格 为 _ + * - 去 BOM + * - trim + * - ✅ 统一处理尾部多余的 ? / _(解决 xxx?_ 与 xxx?) + * + * 注意:为了避免误伤中文表头,这里默认不做 strtolower / 空格替换。 + * 若你后续想增强,可在注释处开启。 */ protected function normalizeHeader(string $header): string { @@ -149,6 +196,17 @@ class FbLeadHeaderMapper // 去掉前后空白 $header = trim($header); + if ($header === '') { + return ''; + } + + // 可选增强(默认不开启,避免影响中文): + // $header = mb_strtolower($header); + // $header = preg_replace('/\s+/', '_', $header); + + // ✅ 去掉末尾连续的 "_" / "?" + $header = rtrim($header, "_?"); + return $header; } } diff --git a/app/process/InquiryAutoProcess.php b/app/process/InquiryAutoProcess.php new file mode 100644 index 0000000..3b8eec1 --- /dev/null +++ b/app/process/InquiryAutoProcess.php @@ -0,0 +1,97 @@ +interval = $interval; + } + + public function onWorkerStart() + { + // 每 interval 秒执行一次 + Timer::add($this->interval, function () { + $this->runOnceForAllChannels(); + }); + } + + /** + * 一轮执行:扫描 config/inquiry_channel.php 中所有渠道,逐个跑一遍流水线 + */ + protected function runOnceForAllChannels() + { + $configAll = config('inquiry_channel'); + if (empty($configAll) || !is_array($configAll)) { + Log::error('InquiryAutoProcess: config("inquiry_channel") 为空或格式不正确'); + return; + } + + foreach ($configAll as $channelKey => $cfg) { + if (!is_array($cfg)) { + Log::error("InquiryAutoProcess: 渠道 {$channelKey} 配置不是数组,已跳过"); + continue; + } + + $this->runForChannel($channelKey, $cfg); + } + } + + /** + * 对单个渠道执行:导入 Excel -> fb_leads -> inquiry_log -> 分配 + 推队列 + */ + protected function runForChannel(string $channelKey, array $cfg) + { + $watchDir = public_path($cfg['watch_dir'] ?? 'excel'); + $enterUserId = (int)($cfg['enter_user_id'] ?? 0); + $channelId = (int)($cfg['channel_id'] ?? 0); + $salesUserIds = $cfg['sales_user_ids'] ?? []; + $roundRobinKey = $cfg['round_robin_key'] ?? $channelKey; + + if ($enterUserId <= 0 || $channelId <= 0) { + Log::error("InquiryAutoProcess: 渠道 {$channelKey} 配置缺少正确的 enter_user_id 或 channel_id,已跳过"); + return; + } + + $importService = new FbFileImportService(); + $syncService = new FbLeadSyncService(); + $assignService = new InquiryAssignService(); + + try { + // 1) 导入该渠道目录下的 Excel 到 fb_leads(fb_leads.channel = 渠道ID) + $filesImported = $importService->importDir($watchDir, $channelId); + + // 2) 只同步该渠道的 fb_leads -> inquiry_log(channel 过滤) + $synced = $syncService->syncToInquiryLog($enterUserId, $channelId); + + // 3) 只分配该渠道的 inquiry_log(channel 过滤) + // $assigned = $assignService->assignBatch($salesUserIds, $roundRobinKey, $channelId, 100); + $assigned = $assignService->assignBatch( + $salesUserIds, + $roundRobinKey, + $channelId, + $cfg['assign_rules'] ?? [], + 100 + ); + + Log::info("InquiryAutoProcess[{$channelKey}] files={$filesImported}, synced={$synced}, assigned={$assigned}"); + } catch (\Throwable $e) { + Log::error( + 'InquiryAutoProcess[' . $channelKey . '] error: ' . + $e->getMessage() . "\n" . $e->getTraceAsString() + ); + } + } +} diff --git a/app/queue/redis/PermAgt.php b/app/queue/redis/PermAgt.php index 42a4898..7c570cb 100644 --- a/app/queue/redis/PermAgt.php +++ b/app/queue/redis/PermAgt.php @@ -53,6 +53,10 @@ class PermAgt implements Consumer 'role_id' => 2 ]; $systems = [ + [ + 'user_id' => $userId, + 'system_id' => 1 + ], [ 'user_id' => $userId, 'system_id' => 2 diff --git a/config/inquiry_channel.php b/config/inquiry_channel.php new file mode 100644 index 0000000..35e7af4 --- /dev/null +++ b/config/inquiry_channel.php @@ -0,0 +1,69 @@ + [ + // 监听目录(相对 public_path) + 'watch_dir' => 'excel/fb_paika_shemei', + + // 清洗到 inquiry_log 时使用的录入人 & 渠道 + 'enter_user_id' => 186, + 'channel_id' => 10, // 👈 这里就是你系统里的渠道ID + + // 参与轮询分配的业务员 ID 列表 + 'sales_user_ids' => [ + 63, + 67, + 93, + 108, + 132, + 143, + 145, + 157, + 160, + 168, + 172, + 173, + 191, + 193, + 194, + 195, + 196, + 197, + 200 + ], + + // 轮询用的分组 key(Redis 记指针用) + 'round_robin_key'=> 'fb_paika_shemei_inquiry', + + // ✅ 渠道内分配规则(按顺序匹配,先命中先用) + 'assign_rules' => [ + [ + 'name' => '加纳池', + 'countries' => [82], + 'sales_user_ids' => [93], + 'round_robin_key' => 'fb_paika_shemei_gh', + ] + ], + ], + + // 合塑社媒 + 'fb_hesu_shemei' => [ + 'watch_dir' => 'excel/fb_hesu_shemei', + 'enter_user_id' => 186, + 'channel_id' => 6, + 'sales_user_ids' => [ + 22, 23, 34, 44, 49, 71, 72, 73, 96, 104, 125, 135, 155 + ], + 'round_robin_key'=> 'fb_hesu_shemei_inquiry', + // ✅ 渠道内分配规则(按顺序匹配,先命中先用) + 'assign_rules' => [ + [ + 'name' => '加纳池', + 'countries' => [82], + 'sales_user_ids' => [44], + 'round_robin_key' => 'fb_hesu_shemei_gh', + ] + ], + ], +]; diff --git a/config/process.php b/config/process.php index 8022ef3..e04f409 100644 --- a/config/process.php +++ b/config/process.php @@ -69,4 +69,12 @@ return [ 'checkweb' => [ 'handler' => app\process\CheckWeb::class ], + 'inquiry_auto_all' => [ + 'handler' => app\process\InquiryAutoProcess::class, + 'count' => 1, + 'reloadable' => true, + 'constructor' => [ + 'interval' => 30, // 每 30 秒跑一轮所有渠道 + ], + ], ]; From 8fab10b97253bb53bf4b032ff7e7478627e67d2b Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 12 Dec 2025 18:11:44 +0800 Subject: [PATCH 07/14] ts --- app/inquiry/service/FbFileImportService.php | 74 +++++++++++++++++++-- 1 file changed, 68 insertions(+), 6 deletions(-) diff --git a/app/inquiry/service/FbFileImportService.php b/app/inquiry/service/FbFileImportService.php index c598158..94e11c4 100644 --- a/app/inquiry/service/FbFileImportService.php +++ b/app/inquiry/service/FbFileImportService.php @@ -91,18 +91,35 @@ class FbFileImportService case 'is_organic': $item['is_organic'] = $this->normalizeIsOrganic($raw); break; + case 'fb_created_time': - // ✅ 方案B:写入 DATETIME 字符串 - $item['fb_created_time'] = $this->normalizeCreatedTime($raw); + $item['fb_created_time'] = $this->normalizeCreatedTime($raw); // 你方案B的datetime break; + + // ✅ 这些是数值ID字段:可能出现 ag:/as:/c:/f:/l: 前缀 + case 'ad_id': + case 'adset_id': + case 'campaign_id': + case 'form_id': + $item[$field] = $this->normalizeNumericId($raw); + break; + + // ✅ lead id 也经常是 l:xxxx + case 'fb_lead_id': + $item['fb_lead_id'] = $this->normalizeNumericId($raw); + break; + + // ✅ 电话可能是 p:+233xxxx + case 'phone': + $item['phone'] = $this->normalizePhone($raw); + break; + default: - // 小增强:字符串统一 trim(避免 " xxx ") - if (is_string($raw)) { - $raw = trim($raw); - } + if (is_string($raw)) $raw = trim($raw); $item[$field] = $raw; break; } + } // 未知列收集到 extra_data(宽松模式) @@ -131,6 +148,51 @@ class FbFileImportService return count($insertData); } + /** + * 把 "ag:120..." / "l:831..." / "120..." 这种值清洗成纯数字(int 或 string数字) + * - 如果你的 DB 字段是 BIGINT UNSIGNED,PHP int 在 64 位环境下是够用的 + * - 解析失败返回 0(避免 insert 失败) + */ + protected function normalizeNumericId($raw) + { + if ($raw === null || $raw === '') return 0; + + // 数字直接返回 + if (is_int($raw)) return $raw; + if (is_float($raw)) return (int)$raw; + + $s = trim((string)$raw); + if ($s === '') return 0; + + // 取出所有数字拼成一段(ag:120... -> 120..., l:xxx -> xxx) + $digits = preg_replace('/\D+/', '', $s); + if ($digits === '') return 0; + + // 这里返回字符串数字也可以,MySQL 会转;返回 int 在 64 位一般没问题 + // 若你担心超出 int,可直接 return $digits; + return $digits; + } + + /** + * 电话清洗:去掉 "p:" 前缀,保留 + 和数字 + */ + protected function normalizePhone($raw): string + { + if ($raw === null) return ''; + + $s = trim((string)$raw); + if ($s === '') return ''; + + // 去掉类似 "p:" "tel:" 等前缀 + $s = preg_replace('/^[a-zA-Z]+:\s*/', '', $s); + + // 保留 + 和数字 + $s = preg_replace('/[^\d\+]+/', '', $s); + + return $s; + } + + /** * 规范化 is_organic 字段为 0/1 */ From cd622addec78774f48ae1b4dcd6a83513d091902 Mon Sep 17 00:00:00 2001 From: mzl Date: Fri, 12 Dec 2025 18:37:03 +0800 Subject: [PATCH 08/14] ts --- app/inquiry/utils/FbLeadHeaderMapper.php | 69 ++++++++++++++++++++---- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/app/inquiry/utils/FbLeadHeaderMapper.php b/app/inquiry/utils/FbLeadHeaderMapper.php index 60b17dc..8f9e888 100644 --- a/app/inquiry/utils/FbLeadHeaderMapper.php +++ b/app/inquiry/utils/FbLeadHeaderMapper.php @@ -78,6 +78,44 @@ class FbLeadHeaderMapper 'email', ]; + /** + * 表头别名:标准化后表头 => headerMap里的key + */ + protected array $aliases = [ + // ===== full_name ===== + 'name' => 'full_name', + 'full_name' => 'full_name', + 'full_name_(full_name)' => 'full_name', + 'full_name_(full_name_)' => 'full_name', + 'full_name_full_name' => 'full_name', + 'full name' => 'full_name', // 万一没被替换成 _ + 'full_name(full_name)' => 'full_name', + '姓名' => 'full_name', + '姓名_full_name' => 'full_name', + '姓名(full_name)' => 'full_name', + + // ===== phone ===== + 'phone' => 'phone', + 'phone_number' => 'phone_number', + 'phone number' => 'phone_number', + 'telephone' => 'phone', + 'tel' => 'phone', + 'mobile' => 'phone', + 'mobile_number' => 'phone', + '手机号' => 'phone', + '电话' => 'phone', + '电话号码' => 'phone', + 'whatsapp' => 'phone', // 有的表单把 whatsapp 当电话 + + // ===== email ===== + 'email' => 'email', + 'e-mail' => 'email', + 'mail' => 'email', + '邮箱' => 'email', + '电子邮箱' => 'email', + ]; + + /** * 返回所有支持的 Excel 表头(标准化后的 key) */ @@ -92,6 +130,7 @@ class FbLeadHeaderMapper public function mapHeader(string $rawHeader): ?string { $key = $this->normalizeHeader($rawHeader); + $key = $this->aliases[$key] ?? $key; return $this->headerMap[$key] ?? null; } @@ -146,7 +185,9 @@ class FbLeadHeaderMapper continue; } - $field = $this->headerMap[$key] ?? null; + // $field = $this->headerMap[$key] ?? null; + $aliasKey = $this->aliases[$key] ?? $key; + $field = $this->headerMap[$aliasKey] ?? null; if ($field === null) { // 宽松:记录未知列(保留原始表头用于展示) @@ -192,21 +233,31 @@ class FbLeadHeaderMapper { // 去掉 UTF-8 BOM $header = preg_replace('/^\xEF\xBB\xBF/', '', $header); - - // 去掉前后空白 $header = trim($header); + if ($header === '') return ''; - if ($header === '') { - return ''; - } + // 统一全角括号 + $header = str_replace(['(',')'], ['(',')'], $header); - // 可选增强(默认不开启,避免影响中文): - // $header = mb_strtolower($header); - // $header = preg_replace('/\s+/', '_', $header); + // 小写化(对英文有效,中文不受影响) + $header = mb_strtolower($header); + + // 把括号内容保留下来,但用下划线连接: "姓名(Full name)" -> "姓名_full_name" + $header = preg_replace('/\((.*?)\)/', '_$1', $header); + + // 空白转下划线 + $header = preg_replace('/\s+/', '_', $header); + + // 连续下划线压缩 + $header = preg_replace('/_+/', '_', $header); + + // 去掉首尾下划线 + $header = trim($header, '_'); // ✅ 去掉末尾连续的 "_" / "?" $header = rtrim($header, "_?"); return $header; } + } From c29c4941fcf5cb2317fc410d7fc74fa68a66b1ee Mon Sep 17 00:00:00 2001 From: mzl Date: Mon, 15 Dec 2025 16:06:51 +0800 Subject: [PATCH 09/14] Merge branch 'develop' --- config/imap.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/config/imap.php b/config/imap.php index de9d8f1..7717117 100644 --- a/config/imap.php +++ b/config/imap.php @@ -22,13 +22,23 @@ return [ 'from_name' => '' ], + // 'smtp_xyh' => [ + // 'host' => 'smtp.feishu.cn', + // 'port' => 465, + // 'username' => 'app@hnxinyouhe.com', + // 'password' => 'akyIUFJ6ihGGjI5h', + // 'encryption' => 'ssl', + // 'from_address' => 'app@hnxinyouhe.com', + // 'from_name' => '【XYH】' + // ], + 'smtp_xyh' => [ 'host' => 'smtp.feishu.cn', 'port' => 465, - 'username' => 'app@hnxinyouhe.com', - 'password' => 'akyIUFJ6ihGGjI5h', + 'username' => 'x@r49.cn', + 'password' => 'BhcWGdBdkLYTgK3w', 'encryption' => 'ssl', - 'from_address' => 'app@hnxinyouhe.com', + 'from_address' => 'x@r49.cn', 'from_name' => '【XYH】' ], ]; From 56f865788607abaffbd64ac8469e6a4f3ab1dca0 Mon Sep 17 00:00:00 2001 From: mzl Date: Tue, 16 Dec 2025 13:44:38 +0800 Subject: [PATCH 10/14] ts --- config/inquiry_channel.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/inquiry_channel.php b/config/inquiry_channel.php index 35e7af4..08d95bd 100644 --- a/config/inquiry_channel.php +++ b/config/inquiry_channel.php @@ -53,7 +53,7 @@ return [ 'enter_user_id' => 186, 'channel_id' => 6, 'sales_user_ids' => [ - 22, 23, 34, 44, 49, 71, 72, 73, 96, 104, 125, 135, 155 + 22, 23, 34, 44, 49, 71, 72, 96, 104, 125, 135, 155 ], 'round_robin_key'=> 'fb_hesu_shemei_inquiry', // ✅ 渠道内分配规则(按顺序匹配,先命中先用) From 5aadcac7e338cf3814e78a8d8041c50ce3f56018 Mon Sep 17 00:00:00 2001 From: mzl Date: Tue, 16 Dec 2025 14:22:21 +0800 Subject: [PATCH 11/14] ts --- config/inquiry_channel.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/config/inquiry_channel.php b/config/inquiry_channel.php index 08d95bd..bc1c096 100644 --- a/config/inquiry_channel.php +++ b/config/inquiry_channel.php @@ -14,7 +14,6 @@ return [ 'sales_user_ids' => [ 63, 67, - 93, 108, 132, 143, @@ -53,7 +52,7 @@ return [ 'enter_user_id' => 186, 'channel_id' => 6, 'sales_user_ids' => [ - 22, 23, 34, 44, 49, 71, 72, 96, 104, 125, 135, 155 + 22, 23, 34, 49, 71, 72, 96, 104, 125, 135, 155 ], 'round_robin_key'=> 'fb_hesu_shemei_inquiry', // ✅ 渠道内分配规则(按顺序匹配,先命中先用) From 31a610664f3779faf8c01991d9b90b0348b52f07 Mon Sep 17 00:00:00 2001 From: mzl Date: Thu, 18 Dec 2025 15:10:43 +0800 Subject: [PATCH 12/14] inquiry ts --- config/inquiry_channel.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config/inquiry_channel.php b/config/inquiry_channel.php index bc1c096..ebaf1ab 100644 --- a/config/inquiry_channel.php +++ b/config/inquiry_channel.php @@ -24,10 +24,10 @@ return [ 172, 173, 191, - 193, + // 193, 194, 195, - 196, + // 196, 197, 200 ], From 7d1ea71e94002473a0c2627e51850433137dbecc Mon Sep 17 00:00:00 2001 From: mzl Date: Mon, 22 Dec 2025 16:22:20 +0800 Subject: [PATCH 13/14] ts --- app/inquiry/service/FbFileImportService.php | 72 +++++++++++++-------- 1 file changed, 45 insertions(+), 27 deletions(-) diff --git a/app/inquiry/service/FbFileImportService.php b/app/inquiry/service/FbFileImportService.php index 94e11c4..93c3474 100644 --- a/app/inquiry/service/FbFileImportService.php +++ b/app/inquiry/service/FbFileImportService.php @@ -209,55 +209,73 @@ class FbFileImportService } /** - * ✅ 方案B:把 FB 创建时间统一转成 MySQL DATETIME(Y-m-d H:i:s) - * - * 支持: - * - "10/13/25" / "12/8/25" - * - "2025-12-08 13:22:11" - * - Excel 日期序列(数字) - * - * 解析失败:返回 null(让 DB 用默认值/允许为空) - * 注意:如果你的 fb_created_time 列是 NOT NULL 且无默认值,请把 null 改成 '1970-01-01 00:00:00' + * 方案B:写 DATETIME,且永不为 null(因为 fb_created_time NOT NULL) + * 兼容: + * - Excel 数字日期 + * - 10/13/25 + * - 2025-12-10 04:25:09 + * - 25年12月21日 / 2025年12月21日 + * - 25年12月21日 13:05 / 25年12月21日13:05:09 + * 为空/解析失败:用当前时间兜底 */ - protected function normalizeCreatedTime($raw): ?string + protected function normalizeCreatedTime($raw): string { - if ($raw === null || $raw === '') { - return null; - } + $fallback = date('Y-m-d H:i:s'); + + if ($raw === null) return $fallback; // Excel 日期序列:数字 if (is_numeric($raw)) { - $n = (float)$raw; - // Excel serial date -> unix timestamp + $n = (float)$raw; $ts = (int)round(($n - 25569) * 86400); - if ($ts > 0) { - return date('Y-m-d H:i:s', $ts); - } - return null; + if ($ts > 0) return date('Y-m-d H:i:s', $ts); + return $fallback; } $s = trim((string)$raw); - if ($s === '') { - return null; - } + if ($s === '') return $fallback; - // 1) strtotime 能解析很多格式 + // 先尝试 strtotime(能覆盖很多格式:2025-12-10 04:25:09 / 2025/12/10 等) $ts = strtotime($s); if ($ts !== false) { return date('Y-m-d H:i:s', $ts); } - // 2) 兼容 m/d/yy(如 10/13/25) + // m/d/yy:10/13/25 if (preg_match('#^(\d{1,2})/(\d{1,2})/(\d{2})$#', $s, $m)) { $mm = (int)$m[1]; $dd = (int)$m[2]; $yy = (int)$m[3]; - $year = 2000 + $yy; // FB 导出通常是 20xx + $year = 2000 + $yy; $ts2 = mktime(0, 0, 0, $mm, $dd, $year); return date('Y-m-d H:i:s', $ts2); } - // 3) 兜底:无法解析 - return null; + // ✅ 中文日期:25年12月21日 / 2025年12月21日(可带时间) + // 支持: + // - 25年12月21日 + // - 2025年12月21日 + // - 25年12月21日 13:05 + // - 25年12月21日13:05:09 + $cn = preg_replace('/\s+/', '', $s); // 去空格方便匹配 + if (preg_match('/^(\d{2,4})年(\d{1,2})月(\d{1,2})日(?:(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?)?$/u', $cn, $m)) { + $y = (int)$m[1]; + $month = (int)$m[2]; + $day = (int)$m[3]; + + // 2位年 -> 2000+(你这里是 25年 = 2025) + if ($y < 100) $y += 2000; + + $hh = isset($m[4]) && $m[4] !== '' ? (int)$m[4] : 0; + $ii = isset($m[5]) && $m[5] !== '' ? (int)$m[5] : 0; + $ss = isset($m[6]) && $m[6] !== '' ? (int)$m[6] : 0; + + $ts3 = mktime($hh, $ii, $ss, $month, $day, $y); + return date('Y-m-d H:i:s', $ts3); + } + + // 解析失败兜底:当前时间 + return $fallback; } + } From 861b8b216f839553e49e949a396a5c7179fa58ca Mon Sep 17 00:00:00 2001 From: mzl Date: Tue, 23 Dec 2025 14:35:57 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=87=E8=B4=A7?= =?UTF-8?q?=E5=8D=95=E4=BF=AE=E6=94=B9=E5=85=9C=E5=BA=95=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/common/service/StockOrdersService.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/common/service/StockOrdersService.php b/app/common/service/StockOrdersService.php index 7ee30a4..1ffd2ff 100644 --- a/app/common/service/StockOrdersService.php +++ b/app/common/service/StockOrdersService.php @@ -454,6 +454,12 @@ class StockOrdersService // 检查必填文件是否已上传 // $this->validateRequiredFiles($data); + $param = $request->all(); + $info = StockOrders::find($param['id']); + if (!$info || $info->approval_status != 0) { + throw new BusinessException('该单不存在或状态异常,请关闭或刷新页面后重试~'); + } + $data['biz_date'] = strtotime($data['biz_date']); $data['eta_ship_date'] = strtotime($data['eta_ship_date']); $data['latest_arrival_date'] = strtotime($data['latest_arrival_date']);