Merge branch 'develop'

This commit is contained in:
mzl
2025-12-10 20:18:46 +08:00
parent 2a6d517a74
commit 628429af66
28 changed files with 4436 additions and 548 deletions
+2
View File
@@ -12,3 +12,5 @@
/windows.php /windows.php
/bootstrap.php /bootstrap.php
/composer.lock /composer.lock
.env_copy
/public/excel
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace app\command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use support\Redis;
use support\exception\BusinessException;
use Webman\RedisQueue\Redis as RedisQueueRedis;
/**
* 轮询分配 inquiry_log 里的询盘给业务员,并推送到 CRM 队列
*/
class InquiryAssignInquiryCommand extends Command
{
protected static $defaultName = 'iq-assign';
protected static $defaultDescription = '轮询分配询盘给业务员,并推送到 CRM 队列';
/**
* 参与轮询的业务员 ID 列表(请按你想要的顺序填写)
* 比如:销售1=186,销售2=233...
*/
protected const SALES_USER_IDS = [
63,
64,
67,
93,
108,
132,
143,
145,
157,
160,
168,
172,
173,
191,
193,
194,
195,
196,
197
];
/**
* 轮询分配的分组 key(同一分组共用一个轮询指针)
*/
protected const ROUND_ROBIN_GROUP_KEY = 'fb_inquiry';
/**
* 每次脚本最多分配多少条
*/
protected const BATCH_LIMIT = 10;
protected function execute(InputInterface $input, OutputInterface $output): int
{
// 业务员列表检查
$userIds = array_values(array_filter(
array_map('intval', static::SALES_USER_IDS),
fn($v) => $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('<comment>没有需要分配的询盘(status=0 且 recipient=0)</comment>');
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("<comment>询盘 {$inquiryId} 未更新(可能已被处理)</comment>");
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("<info>已分配询盘 {$inquiryId} 给业务员 {$recipientUserId},并推送到队列</info>");
});
}
$output->writeln("<info>本次共分配成功 {$count} 条询盘</info>");
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];
}
}
+164
View File
@@ -0,0 +1,164 @@
<?php
namespace app\command;
use app\inquiry\utils\PhoneCountryHelper;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
/**
* 将 fb_leads 数据清洗后导入 inquiry_log
*/
class InquiryFbLeadCommand extends Command
{
protected static $defaultName = 'iq-fb-to';
protected static $defaultDescription = '将 fb_leads 数据清洗后导入 inquiry_log';
// 这些常量根据你实际业务改
protected const DEFAULT_ENTER_USER_ID = 186;
protected const DEFAULT_CHANNEL = 10;
protected function execute(InputInterface $input, OutputInterface $output): int
{
// 只取未同步的记录
$leads = Db::table('fb_leads')
->where('sync_status', 0)
->get();
if ($leads->isEmpty()) {
$output->writeln('<comment>没有需要同步的 fb_leads(sync_status = 0)</comment>');
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('<info>已导入 ' . count($insert) . ' 条数据到 inquiry_log,并更新 fb_leads.sync_status = 1</info>');
}
});
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[] = '<p><strong>意向车型:</strong>' . $e($lead['car_model']) . '</p>';
}
if (!empty($lead['car_quantity']) || !empty($lead['car_quantity_text'])) {
$qty = $lead['car_quantity'] ?? $lead['car_quantity_text'] ?? '';
$lines[] = '<p><strong>意向数量:</strong>' . $e($qty) . '</p>';
}
if (!empty($lead['favorite_color'])) {
$lines[] = '<p><strong>意向颜色:</strong>' . $e($lead['favorite_color']) . '</p>';
}
if (!empty($lead['usage_type'])) {
$lines[] = '<p><strong>用途:</strong>' . $e($lead['usage_type']) . '</p>';
}
if (!empty($lead['seat_preference'])) {
$lines[] = '<p><strong>座位数偏好:</strong>' . $e($lead['seat_preference']) . '</p>';
}
$lines[] = '<hr />';
$lines[] = '<p><strong>广告与表单信息:</strong></p>';
$lines[] = '<p>平台:' . $e($lead['platform'] ?? '') . '</p>';
$lines[] = '<p>Campaign:' . $e($lead['campaign_name'] ?? '') . ' (#' . $e($lead['campaign_id'] ?? '') . ')</p>';
$lines[] = '<p>Adset:' . $e($lead['adset_name'] ?? '') . ' (#' . $e($lead['adset_id'] ?? '') . ')</p>';
$lines[] = '<p>Ad:' . $e($lead['ad_name'] ?? '') . ' (#' . $e($lead['ad_id'] ?? '') . ')</p>';
$lines[] = '<p>Form:' . $e($lead['form_name'] ?? '') . ' (#' . $e($lead['form_id'] ?? '') . ')</p>';
$lines[] = '<hr />';
$lines[] = '<p><strong>Meta 信息:</strong></p>';
$lines[] = '<p>Lead ID:' . $e($lead['fb_lead_id'] ?? '') . '</p>';
$lines[] = '<p>FB 创建时间原始值:' . $e($lead['fb_created_time'] ?? '') . '</p>';
if (!empty($lead['inbox_url'])) {
$url = $e($lead['inbox_url']);
$lines[] = '<p>Inbox 链接:<a href="' . $url . '" target="_blank">' . $url . '</a></p>';
}
return implode("\n", $lines);
}
}
+256
View File
@@ -0,0 +1,256 @@
<?php
namespace app\command;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use PhpOffice\PhpSpreadsheet\IOFactory;
use app\inquiry\utils\FbLeadHeaderMapper;
use support\exception\BusinessException;
class InquiryImportFbCommand extends Command
{
protected static $defaultName = 'iq-fb';
protected static $defaultDescription = '导入 Facebook Leads Excel 数据';
protected function configure(): void
{
$this
->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("<info>开始导入单个文件:</info> {$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("<comment>目录 {$baseDir} 下没有找到 .xlsx 文件</comment>");
return self::SUCCESS;
}
$output->writeln("<info>开始批量导入目录:</info> {$baseDir}");
foreach ($files as $filePath) {
$output->writeln(" -> 文件:{$filePath}");
try {
$this->importOneFile($filePath, $output);
$output->writeln(" <info>导入成功</info>");
} catch (\Throwable $e) {
// 某个文件失败,不影响后面的文件
$output->writeln(" <error>导入失败:{$e->getMessage()}</error>");
}
}
}
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(" <comment>文件 {$filePath} 没有有效数据行</comment>");
}
}
/**
* 将 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"
*/
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace app\command;
use app\common\model\User;
use app\common\model\UserSn;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use support\Db;
use app\common\utils\email\Emails;
/**
* 批量处理用户编号(也用于调试)
*/
class VehMoneyCommand extends Command
{
protected static $defaultName = 'veh-money';
protected static $defaultDescription = '';
protected function execute(InputInterface $input, OutputInterface $output): int
{
return self::SUCCESS;
}
}
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\UsedcarCrawlResultsService;
class UsedcarCrawlResultsController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->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;
// }
}
@@ -0,0 +1,33 @@
<?php
namespace app\common\controller;
use app\common\controller\BaseController;
use app\common\traits\crud\CrudControllerTrait;
use app\common\service\UsedcarCrawlResultsImagesService;
class UsedcarCrawlResultsImagesController extends BaseController
{
use CrudControllerTrait;
protected $service;
public function __construct()
{
$this->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;
// }
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\UsedcarCrawlResultsImages;
class UsedcarCrawlResultsImagesLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResultsImages::class;
}
@@ -0,0 +1,17 @@
<?php
namespace app\common\logic;
use app\common\traits\crud\CrudLogicTrait;
use app\common\model\UsedcarCrawlResults;
class UsedcarCrawlResultsLogic
{
use CrudLogicTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResults::class;
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class UsedcarCrawlResults extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'usedcar_crawl_results';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'used_car_id', 'car_id', 'url', 'car_id_url', 'fuel_form', 'classify_energy', 'brand_name', 'series_name', 'car_name', 'year', 'title', 'price', 'new_price', 'price_difference', 'usage_mileage', 'condition_desc', 'images', 'used_detail', 'config_detail', 'format_shop_info', 'format_car_info', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,49 @@
<?php
namespace app\common\model;
use support\Model;
class UsedcarCrawlResultsImages extends Model
{
/**
* The connection name for the model.
*
* @var string|null
*/
protected $connection = 'mysql';
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'usedcar_crawl_results_images';
/**
* The primary key associated with the table.
*
* @var string
*/
protected $primaryKey = 'id';
/**
* Indicates if the model should be timestamped.
*
* @var bool
*/
public $timestamps = true;
protected $dateFormat = 'U';
/**
* 允许自动填充模型字段
*/
protected $fillable = ['id', 'uc_id', 'used_car_id', 'original_name', 'file_path', 'created_at', 'updated_at'];
/**
* 不允许自动填充模型字段
*/
protected $guarded = [];
}
@@ -0,0 +1,31 @@
<?php
namespace app\common\service;
use app\common\validate\UsedcarCrawlResultsImagesValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\UsedcarCrawlResultsImages;
class UsedcarCrawlResultsImagesService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResultsImages::class;
/**
* 验证器
*/
protected $validator = UsedcarCrawlResultsImagesValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
@@ -0,0 +1,31 @@
<?php
namespace app\common\service;
use app\common\validate\UsedcarCrawlResultsValidate;
use app\common\traits\crud\CrudServiceTrait;
use app\common\model\UsedcarCrawlResults;
class UsedcarCrawlResultsService
{
use CrudServiceTrait;
/**
* 指定当前要操作的模型
*/
protected string $modelClass = UsedcarCrawlResults::class;
/**
* 验证器
*/
protected $validator = UsedcarCrawlResultsValidate::class;
/**
* 获取验证器
*/
public function getValidator()
{
return new $this->validator;
}
}
@@ -0,0 +1,857 @@
<?php
namespace app\common\utils\crawl\service;
use app\common\model\UsedcarCrawlResultsImages;
use app\common\service\UploadService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use QL\QueryList;
use support\Redis;
use support\Db;
use support\exception\BusinessException;
use Throwable;
class UsedCarCrawl
{
// 默认请求头
private const DEFAULT_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/',
];
// 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;
}
}
@@ -0,0 +1,681 @@
<?php
namespace app\common\utils\crawl\service;
use GuzzleHttp\Client;
use QL\QueryList;
use support\Redis;
use support\Db;
use support\exception\BusinessException;
class UsedCarCrawl_bak
{
/**
* 插入队列url
*
* $scene 二手信息 usedcar 配置信息 config
*/
public function iUrl(string $url = '', string $scene = 'usedcar', int $id = 0)
{
return Redis::lpush('usedcar:urls', json_encode([
'scene' => $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;
}
}
@@ -0,0 +1,798 @@
<?php
namespace app\common\utils\crawl\service;
use app\common\model\UsedcarCrawlResultsImages;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use QL\QueryList;
use support\Redis;
use support\Db;
use support\exception\BusinessException;
use Throwable;
class UsedCarCrawl_bak2
{
// 默认请求头
private const DEFAULT_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/'
];
// 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);
}
}
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class UsedcarCrawlResultsImagesValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
@@ -0,0 +1,29 @@
<?php
namespace app\common\validate;
use think\Validate;
class UsedcarCrawlResultsValidate extends Validate
{
// 规则
protected $rule = [
];
// 提示
protected $message = [
];
// 场景
protected $scene = [
'store' => [
],
'update' => [
]
];
}
+15 -10
View File
@@ -74,25 +74,25 @@ class IndexController
$options = [ $options = [
'controller' => [ 'controller' => [
'module' => 'common', 'module' => 'common',
'name' => 'finance_recon_dubai_files' 'name' => 'usedcar_crawl_results_images'
], ],
'model' => [ 'model' => [
'module' => 'common', 'module' => 'common',
'name' => 'finance_recon_dubai_files' 'name' => 'usedcar_crawl_results_images'
], ],
'service' => [ 'service' => [
'module' => 'common', 'module' => 'common',
'name' => 'finance_recon_dubai_files' 'name' => 'usedcar_crawl_results_images'
], ],
'logic' => [ 'logic' => [
'module' => 'common', 'module' => 'common',
'name' => 'finance_recon_dubai_files' 'name' => 'usedcar_crawl_results_images'
], ],
'validate' => [ 'validate' => [
'module' => 'common', '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(); $result = (new Generate($options))->doGenerate();
@@ -159,11 +159,16 @@ class IndexController
public function test(Request $request) public function test(Request $request)
{ {
$tmp = (new \app\common\service\OssService)->generatePrivateUrl('fdesk_test/20251104/20251104_财务.xlsx');; $service = new \app\common\utils\crawl\service\UsedCarCrawl();
$service = new \app\common\service\FinanceRecoDubaiFilesService(); $result = $service->pushUrl('https://www.dongchedi.com/usedcar/21631642');
$result = $service->import($tmp, true);
return json(['code' => 0, 'msg' => 'ok', 'data' => $result]); // $result = $service->doData();
// $result = $service->decodeDongchediPrice('.');
// $result = $service->detectEnergyType('油电混合');
return json($result);
} }
} }
+154
View File
@@ -0,0 +1,154 @@
<?php
namespace app\inquiry\utils;
use support\exception\BusinessException;
class FbLeadHeaderMapper
{
/**
* Excel 原始表头 => 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;
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace app\inquiry\utils;
use support\Db;
class PhoneCountryHelper
{
/**
* 根据电话/WhatsApp 号码识别国家 ID
* 号码格式可以是:+996557660304、996557660304、00996557660304 等
*/
public static function detectCountryId(?string $rawPhone): ?int
{
if (!$rawPhone) {
return null;
}
// 1. 只保留数字和+
$phone = preg_replace('/[^\d\+]/', '', $rawPhone);
if ($phone === '' || $phone === null) {
return null;
}
// 2. 统一前缀:00 开头 -> +
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;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace app\inquiry\utils;
use support\Redis;
use support\exception\BusinessException;
class RoundRobinAssigner
{
/**
* Redis key 前缀
* 你可以按业务分组:比如 "fb_inquiry", "website_inquiry" 等
*/
protected const REDIS_KEY_PREFIX = 'inquiry:assign:round_robin:';
/**
* 从一组业务员 ID 中按轮询规则返回“下一个业务员 ID”
*
* @param int[] $userIds 业务员 ID 列表(有顺序)
* @param string $groupKey 分配分组标识(同一个分组共享一个轮询指针)
*
* @return int 下一位业务员 ID
* @throws BusinessException
*/
public static function nextUserId(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);
// 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];
}
}
+6
View File
@@ -22,6 +22,12 @@ class CheckWeb
// }); // });
// } // }
// 每秒执行:队列二手车数据处理
new Crontab('* * * * * *', function(){
$service = new \app\common\utils\crawl\service\UsedCarCrawl();
$service->execute();
});
new Crontab('*/50 * * * * *', function(){ new Crontab('*/50 * * * * *', function(){
$userIds = User::where('status', 1)->pluck('id')->toArray(); $userIds = User::where('status', 1)->pluck('id')->toArray();
foreach ($userIds as $userId) { foreach ($userIds as $userId) {
+1 -1
View File
@@ -15,7 +15,7 @@ class Task
{ {
// 每半小时执行一次,通知待跟进客户 // 每半小时执行一次,通知待跟进客户
new Crontab('0 */30 * * * *', function(){ 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()]); Log::info('send follow start',[time()]);
$where = [ $where = [
+2 -1
View File
@@ -48,7 +48,8 @@
"webman/gateway-worker": "^1.0", "webman/gateway-worker": "^1.0",
"mpdf/mpdf": "^8.2", "mpdf/mpdf": "^8.2",
"jaeger/querylist": "^4.4", "jaeger/querylist": "^4.4",
"zoujingli/ip2region": "^3.0" "zoujingli/ip2region": "^3.0",
"webman/log": "^2.1"
}, },
"suggest": { "suggest": {
"ext-event": "For better performance. " "ext-event": "For better performance. "
Generated
+847 -535
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
<?php
return [
'enable' => true,
'exception' => [
// 是否记录异常到日志
'enable' => true,
// 不会记录到日志的异常类
'dontReport' => [
support\exception\BusinessException::class
]
],
'dontReport' => [
'app' => [],
'controller' => [],
'action' => [],
'path' => []
],
'channel' => 'default' // 日志通道(在config/log.php里配置,默认是default)
];
+21
View File
@@ -0,0 +1,21 @@
<?php
/**
* This file is part of webman.
*
* Licensed under The MIT License
* For full copyright and license information, please see the MIT-LICENSE.txt
* Redistributions of files must retain the above copyright notice.
*
* @author walkor<walkor@workerman.net>
* @copyright walkor<walkor@workerman.net>
* @link http://www.workerman.net/
* @license http://www.opensource.org/licenses/mit-license.php MIT License
*/
use Webman\Log\Middleware;
return [
'@' => [
Middleware::class
]
];