Merge branch 'master' into develop
This commit is contained in:
@@ -11,6 +11,9 @@ use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 主要处理询单详情中的图片地址
|
||||
*/
|
||||
class DomainCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'domain';
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 拉取邮箱的详情入库(根据emai_uid)
|
||||
*/
|
||||
class EmailDetailCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'email-detail';
|
||||
protected static $defaultDescription = '拉取 detail 入库';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
$this->addOption(
|
||||
'per-page', 'p',
|
||||
InputOption::VALUE_OPTIONAL,
|
||||
'每批处理多少条详情',
|
||||
config('imap.per_page', 50)
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
try {
|
||||
$cfg = config('imap');
|
||||
$mailbox = $cfg['mailbox'];
|
||||
$username = $cfg['username'];
|
||||
$password = $cfg['password'];
|
||||
$batch = (int)$input->getOption('per-page');
|
||||
|
||||
// 打开 IMAP
|
||||
$inbox = @imap_open($mailbox, $username, $password);
|
||||
if (!$inbox) {
|
||||
$output->writeln("<error>IMAP 连接失败:". imap_last_error() ."</error>");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 阶段二:逐批拉取详情并写入 emails 表
|
||||
$output->writeln("依次拉取详情并写入 emails 表 …");
|
||||
while (true) {
|
||||
$pending = Db::table('email_uids')
|
||||
->where('status', 0)
|
||||
// ->where('uid', '>', 13399)
|
||||
->orderBy('uid', 'desc')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if (count($pending) === 0) {
|
||||
$output->writeln("<info>所有 UID 均已处理完毕。</info>");
|
||||
break;
|
||||
}
|
||||
|
||||
$output->writeln("处理下一批 " . count($pending) . " 条 …");
|
||||
|
||||
foreach ($pending as $row) {
|
||||
$uid = $row->uid;
|
||||
|
||||
// 包裹整行处理在 try/catch 中,任何异常都不会中断循环
|
||||
try {
|
||||
$output->writeln("---- 开始处理 UID={$uid} ----");
|
||||
|
||||
// 1. fetch overview
|
||||
$ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null;
|
||||
if (!$ov) {
|
||||
$err = imap_last_error() ?: '未知错误';
|
||||
$output->writeln("<error>UID={$uid} overview 失败:{$err}</error>");
|
||||
$this->logError("UID={$uid} overview 失败:{$err}");
|
||||
continue;
|
||||
}
|
||||
$subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8');
|
||||
$from = $ov->from ?? '';
|
||||
$date = $ov->date ?? '';
|
||||
|
||||
$output->writeln("UID={$uid} overview 成功,subject={$subject}");
|
||||
|
||||
// 2. fetch body
|
||||
$body = $this->getMailBody($inbox, $uid, true);
|
||||
$output->writeln("UID={$uid} body 长度:" . strlen($body));
|
||||
|
||||
// 3. sanitize & insert
|
||||
$body = $this->sanitizeUtf8($body);
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
Db::table('emails')->insert([
|
||||
'uid' => $uid,
|
||||
'subject' => $subject,
|
||||
'from' => $from,
|
||||
'date' => $date,
|
||||
'body' => $body,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$output->writeln("<info>UID={$uid} 写入 emails 成功</info>");
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln("<error>UID={$uid} 写入失败:{$e->getMessage()}</error>");
|
||||
$this->logError("UID={$uid} 写入失败:{$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 标记已处理
|
||||
try {
|
||||
Db::table('email_uids')
|
||||
->where('uid', $uid)
|
||||
->update([
|
||||
'status' => 1,
|
||||
'processed_at' => $now,
|
||||
]);
|
||||
$output->writeln("<info>UID={$uid} 标记为已处理</info>");
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln("<error>UID={$uid} 标记状态失败:{$e->getMessage()}</error>");
|
||||
$this->logError("UID={$uid} 标记状态失败:{$e->getMessage()}");
|
||||
// 即使更新 status 失败,也继续下一条
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 捕获任何非预期错误
|
||||
$output->writeln("<error>UID={$uid} 处理异常:{$e->getMessage()}</error>");
|
||||
$this->logError("UID={$uid} 处理异常:{$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 批次结束后稍作休眠,防止服务器过载
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
imap_close($inbox);
|
||||
$output->writeln("\n<info>✅ 全部邮件处理完成。</info>");
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $th) {
|
||||
dpr($th);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意字符串清洗为有效 UTF-8,移除或替换无效字节
|
||||
*/
|
||||
protected function sanitizeUtf8(string $text): string
|
||||
{
|
||||
return iconv('UTF-8', 'UTF-8//IGNORE', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误到日志
|
||||
*/
|
||||
protected function logError(string $msg): void
|
||||
{
|
||||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n";
|
||||
file_put_contents(
|
||||
runtime_path('logs/email_errors.log'),
|
||||
$line,
|
||||
FILE_APPEND | LOCK_EX
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码解码并保留 "\r\n"
|
||||
*/
|
||||
protected function getMailBody($inbox, int $id, bool $isUid = false): string
|
||||
{
|
||||
$flags = $isUid ? FT_UID : 0;
|
||||
$structure = imap_fetchstructure($inbox, $id, $flags);
|
||||
|
||||
if (!empty($structure->parts)) {
|
||||
// HTML 部分
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
if (isset($part->subtype) && strtolower($part->subtype) === 'html') {
|
||||
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
|
||||
return $this->decodeBody($data, $part->encoding);
|
||||
}
|
||||
}
|
||||
// 文本部分
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
if (isset($part->subtype) && strtolower($part->subtype) === 'plain') {
|
||||
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
|
||||
return $this->decodeBody($data, $part->encoding);
|
||||
}
|
||||
}
|
||||
// 原始 body
|
||||
return imap_body($inbox, $id, $flags);
|
||||
}
|
||||
|
||||
return imap_body($inbox, $id, $flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码解码
|
||||
*/
|
||||
protected function decodeBody(string $body, int $encoding): string
|
||||
{
|
||||
switch ($encoding) {
|
||||
case 3: return imap_base64($body);
|
||||
case 4: return imap_qprint($body);
|
||||
default: return $body;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
namespace app\command;
|
||||
|
||||
use support\Db;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
||||
/**
|
||||
* 将拉取邮箱的详情进行清洗,找出询盘的邮件入库(根据emai_detail)
|
||||
*/
|
||||
class EmailParseCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'email-parse';
|
||||
protected static $defaultDescription = '邮件清洗';
|
||||
|
||||
protected function configure()
|
||||
{
|
||||
// 不再接收参数,固定每次 100 条
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$batchSize = 100; // 每次处理 100 条
|
||||
$page = 0;
|
||||
|
||||
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
|
||||
$fields = [
|
||||
'email_id',
|
||||
'date',
|
||||
'email',
|
||||
'name',
|
||||
'tel',
|
||||
'company',
|
||||
'country',
|
||||
'form_url',
|
||||
'message',
|
||||
'ip',
|
||||
'iplocation',
|
||||
'ilang',
|
||||
'mailto',
|
||||
'navigator_language',
|
||||
'navigator_platform',
|
||||
'screen_width',
|
||||
'screen_height',
|
||||
'ucountry',
|
||||
];
|
||||
|
||||
while (true) {
|
||||
$rows = Db::table('emails')
|
||||
->orderBy('id', 'asc')
|
||||
->limit($batchSize)
|
||||
->offset($page * $batchSize)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$toInsert = [];
|
||||
foreach ($rows as $row) {
|
||||
$parsed = $this->parseHtml($row->body);
|
||||
if ($parsed === null) {
|
||||
$output->writeln(" ↳ [{$row->id}] skip: not a contact-form HTML");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
|
||||
$record = [];
|
||||
foreach ($fields as $col) {
|
||||
if ($col === 'email_id') {
|
||||
$record[$col] = $row->id;
|
||||
} else {
|
||||
$record[$col] = $parsed[$col] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$toInsert[] = $record;
|
||||
}
|
||||
|
||||
if (!empty($toInsert)) {
|
||||
Db::table('email_parsed_result')->insert($toInsert);
|
||||
$output->writeln("Inserted batch #" . ($page + 1) . ", count: " . count($toInsert));
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
$output->writeln("✔ All done.");
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 HTML,找不到 <h2>Preview</h2> 则返回 null,
|
||||
* 否则提取所有 <span>Key:</span> Value 为小写蛇形 key=>value 数组
|
||||
*/
|
||||
protected function parseHtml(string $html): ?array
|
||||
{
|
||||
$doc = new \DOMDocument();
|
||||
libxml_use_internal_errors(true);
|
||||
$doc->loadHTML('<?xml encoding="utf-8"?>' . $html);
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new \DOMXPath($doc);
|
||||
|
||||
// 类型检查
|
||||
if ($xpath->query("//h2[text()='Preview']")->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nodes = $xpath->query("//div[span]");
|
||||
$data = [];
|
||||
|
||||
/** @var \DOMElement $node */
|
||||
foreach ($nodes as $node) {
|
||||
$span = $xpath->query("span", $node)->item(0);
|
||||
if (! $span) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 原始键名
|
||||
$key = trim(str_replace(':', '', $span->textContent));
|
||||
// 转成小写蛇形
|
||||
$col = strtolower(str_replace(' ', '_', $key));
|
||||
|
||||
// 获取值
|
||||
$a = $xpath->query(".//a", $node)->item(0);
|
||||
if ($a && $a->hasAttribute('href')) {
|
||||
$value = trim($a->getAttribute('href'));
|
||||
} else {
|
||||
$clone = $node->cloneNode(true);
|
||||
$spanClone = $xpath->query(".//span", $clone)->item(0);
|
||||
if ($spanClone) {
|
||||
$spanClone->parentNode->removeChild($spanClone);
|
||||
}
|
||||
$value = trim($clone->textContent);
|
||||
}
|
||||
|
||||
$data[$col] = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
|
||||
namespace app\command;
|
||||
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Helper\Table;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
|
||||
/**
|
||||
* 拉取邮箱的UID入库
|
||||
*/
|
||||
class EmailUidCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'email-uid';
|
||||
protected static $defaultDescription = '拉取 UID 入库';
|
||||
|
||||
// protected function configure()
|
||||
// {
|
||||
// $this->addOption(
|
||||
// 'per-page', 'p',
|
||||
// InputOption::VALUE_OPTIONAL,
|
||||
// '每批处理多少条详情',
|
||||
// config('imap.per_page', 50)
|
||||
// );
|
||||
// }
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
try {
|
||||
$cfg = config('imap');
|
||||
$mailbox = $cfg['mailbox'];
|
||||
$username = $cfg['username'];
|
||||
$password = $cfg['password'];
|
||||
// $batch = (int)$input->getOption('per-page');
|
||||
|
||||
$retryTimes = 3;
|
||||
$retryDelay = 10; // 秒
|
||||
|
||||
$inbox = false;
|
||||
$attempt = 0;
|
||||
|
||||
$maxUid = Db::table('email_uids')->max('uid');
|
||||
|
||||
while ($attempt < $retryTimes) {
|
||||
$attempt++;
|
||||
$output->writeln("尝试第 {$attempt} 次连接 IMAP...");
|
||||
$inbox = @imap_open($mailbox, $username, $password);
|
||||
|
||||
if ($inbox) {
|
||||
break;
|
||||
}
|
||||
|
||||
$error = imap_last_error();
|
||||
$output->writeln("<comment>第 {$attempt} 次失败:{$error}</comment>");
|
||||
|
||||
// 若出现 too many login failures 立即暂停一段时间
|
||||
if (stripos($error, 'Too many login failures') !== false) {
|
||||
$output->writeln("<error>检测到登录失败次数过多,暂停 60 秒避免封禁</error>");
|
||||
sleep(60);
|
||||
} else {
|
||||
sleep($retryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$inbox) {
|
||||
$output->writeln("<error>最终仍然连接失败:" . imap_last_error() . "</error>");
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// 拉取 UID 并入库
|
||||
$output->writeln("开始拉取 UID 并入库 …");
|
||||
$uids = imap_search($inbox, 'ALL', SE_UID) ?: [];
|
||||
|
||||
$uids = array_filter($uids, function($uid) use ($maxUid) {
|
||||
return $uid > $maxUid;
|
||||
});
|
||||
|
||||
sort($uids);
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
foreach ($uids as $uid) {
|
||||
try {
|
||||
Db::table('email_uids')->insert([
|
||||
'uid' => $uid,
|
||||
'fetched_at' => $now,
|
||||
'email' => $cfg['username']
|
||||
]);
|
||||
$count++;
|
||||
|
||||
$output->writeln("UID:".$uid." 已写入数据库");
|
||||
} catch (\Throwable $e) {
|
||||
$output->writeln("<comment>{$e->getMessage()}</comment>");
|
||||
}
|
||||
}
|
||||
|
||||
imap_close($inbox);
|
||||
$output->writeln(date('Y-m-d H:i:s').":共拉到 {$count} 个 UID 并写入 email_uids 表。");
|
||||
return Command::SUCCESS;
|
||||
} catch (\Throwable $th) {
|
||||
dpr($th);
|
||||
return Command::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定格式的日期字符串
|
||||
*
|
||||
* @param string|null $input_date 可选,指定的日期(可以是任何PHP可解析的日期格式)
|
||||
* @return string 返回格式为 'd-M-Y' 的日期字符串(如 '28-Jul-2025')
|
||||
*/
|
||||
function getImapFormattedDate($input_date = null) {
|
||||
// 如果有传入日期参数,尝试解析
|
||||
if ($input_date !== null) {
|
||||
$timestamp = strtotime($input_date);
|
||||
if ($timestamp === false) {
|
||||
// 如果日期解析失败,使用当前日期并记录警告
|
||||
trigger_error("无法解析提供的日期: '$input_date',将使用当前日期", E_USER_WARNING);
|
||||
$timestamp = time();
|
||||
}
|
||||
} else {
|
||||
// 如果没有提供日期,使用当前日期
|
||||
$timestamp = time();
|
||||
}
|
||||
|
||||
// 格式化为 IMAP 需要的日期格式 (如 '28-Jul-2025')
|
||||
return date('d-M-Y', $timestamp);
|
||||
}
|
||||
|
||||
// // 使用示例
|
||||
// echo getImapFormattedDate(); // 输出当天的日期,如 '31-Jul-2025'
|
||||
// echo getImapFormattedDate('2025-07-28'); // 输出 '28-Jul-2025'
|
||||
// echo getImapFormattedDate('28/07/2025'); // 输出 '28-Jul-2025'
|
||||
// echo getImapFormattedDate('next Monday'); // 输出下个周一的日期
|
||||
|
||||
/*
|
||||
|
||||
// imap_search() 条件
|
||||
|
||||
// 1. 基本日期搜索(按接收日期)
|
||||
// 搜索今天收到的邮件
|
||||
$today = date("d-M-Y");
|
||||
$uids = imap_search($inbox, 'SINCE "' . $today . '"', SE_UID);
|
||||
|
||||
// 搜索特定日期之后的邮件
|
||||
$since_date = '15-Jul-2023';
|
||||
$uids = imap_search($inbox, 'SINCE "' . $since_date . '"', SE_UID);
|
||||
|
||||
// 搜索特定日期之前的邮件
|
||||
$before_date = '20-Jul-2023';
|
||||
$uids = imap_search($inbox, 'BEFORE "' . $before_date . '"', SE_UID);
|
||||
|
||||
// 搜索特定日期的邮件
|
||||
$on_date = '18-Jul-2023';
|
||||
$uids = imap_search($inbox, 'ON "' . $on_date . '"', SE_UID);
|
||||
|
||||
// 2. 日期范围搜索
|
||||
// 搜索2023年7月15日到2023年7月20日之间的邮件
|
||||
$start_date = '15-Jul-2023';
|
||||
$end_date = '20-Jul-2023';
|
||||
$uids = imap_search($inbox, 'SINCE "' . $start_date . '" BEFORE "' . $end_date . '"', SE_UID);
|
||||
|
||||
// 3. 结合日期和其他条件
|
||||
// 搜索2023年7月以后未读的邮件
|
||||
$date = '1-Jul-2023';
|
||||
$uids = imap_search($inbox, 'SINCE "' . $date . '" UNSEEN', SE_UID);
|
||||
|
||||
// 搜索2023年7月来自某人的邮件
|
||||
$date = '1-Jul-2023';
|
||||
$from = 'example@domain.com';
|
||||
$uids = imap_search($inbox, 'SINCE "' . $date . '" FROM "' . $from . '"', SE_UID);
|
||||
|
||||
// 4. 日期格式注意事项
|
||||
// - IMAP日期格式通常为 d-M-Y,例如 15-Jul-2023
|
||||
// - 月份使用英文缩写(Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec)
|
||||
// - 日期格式必须用双引号括起来
|
||||
|
||||
所有ID
|
||||
imap_search($inbox, 'ALL', SE_UID)
|
||||
|
||||
*/
|
||||
@@ -7,6 +7,9 @@ use Symfony\Component\Console\Output\OutputInterface;
|
||||
use support\Db;
|
||||
use support\Log;
|
||||
|
||||
/**
|
||||
* 处理询单中的编号重复
|
||||
*/
|
||||
class FixInquiryLogOrderNo extends Command
|
||||
{
|
||||
protected static $defaultName = 'sn';
|
||||
|
||||
@@ -9,7 +9,11 @@ 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 TbCommand extends Command
|
||||
{
|
||||
protected static $defaultName = 'tb-db';
|
||||
@@ -17,12 +21,16 @@ class TbCommand extends Command
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$user = new User();
|
||||
$list = $user->all();
|
||||
// $user = new User();
|
||||
// $list = $user->all();
|
||||
|
||||
foreach ($list as $item) {
|
||||
// UserSn::where('user_sn', $item->sn)->update(['user_id' => $item->id]);
|
||||
}
|
||||
// foreach ($list as $item) {
|
||||
// // UserSn::where('user_sn', $item->sn)->update(['user_id' => $item->id]);
|
||||
// }
|
||||
|
||||
$m = new Emails();
|
||||
$m->process();
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,7 @@ class AuthLogic
|
||||
->orWhere('email', $email);
|
||||
})
|
||||
->first();
|
||||
$user->isLogin = true;
|
||||
|
||||
if (!$user) {
|
||||
// 获取编号
|
||||
$userSn = $this->getAvailableUserSn();
|
||||
|
||||
@@ -60,7 +60,7 @@ class InquiryLog extends Model
|
||||
return $this->hasOne(User::class, 'id', 'enter_user_id');
|
||||
}
|
||||
|
||||
public function recipient()
|
||||
public function recipientInfo()
|
||||
{
|
||||
return $this->hasOne(User::class, 'id', 'recipient');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\email;
|
||||
|
||||
use support\Db;
|
||||
|
||||
class Emails
|
||||
{
|
||||
|
||||
private $mailbox = '{imap.feishu.cn:993/imap/ssl}INBOX';
|
||||
private $username = 'info@borderlesscar.com';
|
||||
private $password = 'ErkhUA629aocm7Bc';
|
||||
private $limit = 5;
|
||||
private $per_page = 20;
|
||||
private $batch_size = 10;
|
||||
|
||||
|
||||
public function __construct($options = [])
|
||||
{
|
||||
if ($options) {
|
||||
$this->mailbox = $options['mailbox'];
|
||||
$this->username = $options['username'];
|
||||
$this->password = $options['password'];
|
||||
$this->limit = $options['limit'];
|
||||
$this->per_page = $options['per_page'];
|
||||
$this->batch_size = $options['batch_size'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将任意字符串清洗为有效 UTF-8,移除或替换无效字节
|
||||
*/
|
||||
protected function sanitizeUtf8(string $text): string
|
||||
{
|
||||
return iconv('UTF-8', 'UTF-8//IGNORE', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误到日志
|
||||
*/
|
||||
protected function logError(string $msg): void
|
||||
{
|
||||
$line = '[' . date('Y-m-d H:i:s') . '] ' . $msg . "\n";
|
||||
file_put_contents(
|
||||
runtime_path('logs/email_errors.log'),
|
||||
$line,
|
||||
FILE_APPEND | LOCK_EX
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码解码并保留 "\r\n"
|
||||
*/
|
||||
protected function getMailBody($inbox, int $id, bool $isUid = false): string
|
||||
{
|
||||
$flags = $isUid ? FT_UID : 0;
|
||||
$structure = imap_fetchstructure($inbox, $id, $flags);
|
||||
|
||||
if (!empty($structure->parts)) {
|
||||
// HTML 部分
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
if (isset($part->subtype) && strtolower($part->subtype) === 'html') {
|
||||
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
|
||||
return $this->decodeBody($data, $part->encoding);
|
||||
}
|
||||
}
|
||||
// 文本部分
|
||||
foreach ($structure->parts as $i => $part) {
|
||||
if (isset($part->subtype) && strtolower($part->subtype) === 'plain') {
|
||||
$data = imap_fetchbody($inbox, $id, (string)($i+1), $flags);
|
||||
return $this->decodeBody($data, $part->encoding);
|
||||
}
|
||||
}
|
||||
// 原始 body
|
||||
return imap_body($inbox, $id, $flags);
|
||||
}
|
||||
|
||||
return imap_body($inbox, $id, $flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按编码解码
|
||||
*/
|
||||
protected function decodeBody(string $body, int $encoding): string
|
||||
{
|
||||
switch ($encoding) {
|
||||
case 3: return imap_base64($body);
|
||||
case 4: return imap_qprint($body);
|
||||
default: return $body;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 HTML,找不到 <h2>Preview</h2> 则返回 null,
|
||||
* 否则提取所有 <span>Key:</span> Value 为小写蛇形 key=>value 数组
|
||||
*/
|
||||
protected function parseHtml(string $html): ?array
|
||||
{
|
||||
$doc = new \DOMDocument();
|
||||
libxml_use_internal_errors(true);
|
||||
$doc->loadHTML('<?xml encoding="utf-8"?>' . $html);
|
||||
libxml_clear_errors();
|
||||
|
||||
$xpath = new \DOMXPath($doc);
|
||||
|
||||
// 类型检查
|
||||
if ($xpath->query("//h2[text()='Preview']")->length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$nodes = $xpath->query("//div[span]");
|
||||
$data = [];
|
||||
|
||||
/** @var \DOMElement $node */
|
||||
foreach ($nodes as $node) {
|
||||
$span = $xpath->query("span", $node)->item(0);
|
||||
if (! $span) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 原始键名
|
||||
$key = trim(str_replace(':', '', $span->textContent));
|
||||
// 转成小写蛇形
|
||||
$col = strtolower(str_replace(' ', '_', $key));
|
||||
|
||||
// 获取值
|
||||
$a = $xpath->query(".//a", $node)->item(0);
|
||||
if ($a && $a->hasAttribute('href')) {
|
||||
$value = trim($a->getAttribute('href'));
|
||||
} else {
|
||||
$clone = $node->cloneNode(true);
|
||||
$spanClone = $xpath->query(".//span", $clone)->item(0);
|
||||
if ($spanClone) {
|
||||
$spanClone->parentNode->removeChild($spanClone);
|
||||
}
|
||||
$value = trim($clone->textContent);
|
||||
}
|
||||
|
||||
$data[$col] = $value;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取邮件uid
|
||||
*/
|
||||
public function uids()
|
||||
{
|
||||
try {
|
||||
$retryTimes = 3;
|
||||
$retryDelay = 10;
|
||||
$inbox = false;
|
||||
$attempt = 0;
|
||||
|
||||
$maxUid = Db::table('email_uids')->max('uid');
|
||||
|
||||
while ($attempt < $retryTimes) {
|
||||
$attempt++;
|
||||
flg("尝试第 {$attempt} 次连接 IMAP...", false);
|
||||
|
||||
$inbox = @imap_open($this->mailbox, $this->username, $this->password);
|
||||
|
||||
if ($inbox) {
|
||||
break;
|
||||
}
|
||||
|
||||
$error = imap_last_error();
|
||||
flg("<comment>第 {$attempt} 次失败:{$error}</comment>");
|
||||
|
||||
// 若出现 too many login failures 立即暂停一段时间
|
||||
if (stripos($error, 'Too many login failures') !== false) {
|
||||
flg("<error>检测到登录失败次数过多,暂停 60 秒避免封禁</error>");
|
||||
sleep(60);
|
||||
} else {
|
||||
sleep($retryDelay);
|
||||
}
|
||||
}
|
||||
|
||||
if (!$inbox) {
|
||||
flg("<error>最终仍然连接失败:" . imap_last_error() . "</error>");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 拉取 UID 并入库
|
||||
flg("开始拉取 UID 并入库 …", false);
|
||||
$uids = imap_search($inbox, 'ALL', SE_UID) ?: [];
|
||||
|
||||
$uids = array_filter($uids, function($uid) use ($maxUid) {
|
||||
return $uid > $maxUid;
|
||||
});
|
||||
|
||||
sort($uids);
|
||||
$now = time();
|
||||
$count = 0;
|
||||
|
||||
foreach ($uids as $uid) {
|
||||
try {
|
||||
Db::table('email_uids')->insert([
|
||||
'uid' => $uid,
|
||||
'fetched_at' => $now,
|
||||
'email' => $this->username
|
||||
]);
|
||||
$count++;
|
||||
flg("UID:".$uid." 已写入数据库", false);
|
||||
} catch (\Throwable $e) {
|
||||
flg("<comment>{$e->getMessage()}</comment>");
|
||||
}
|
||||
}
|
||||
|
||||
imap_close($inbox);
|
||||
flg("✔ 获取到新询盘邮件 {$count} 条 ...", false);
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
flg(dpr($th));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取邮件详情
|
||||
*/
|
||||
public function details()
|
||||
{
|
||||
try {
|
||||
$inbox = @imap_open($this->mailbox, $this->username, $this->password);
|
||||
if (!$inbox) {
|
||||
flg("<error>IMAP 连接失败:". imap_last_error() ."</error>");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 依次拉取详情并写入 emails 表
|
||||
flg("依次拉取详情并写入 emails 表 …", false);
|
||||
while (true) {
|
||||
$pending = Db::table('email_uids')
|
||||
->where('status', 0)
|
||||
->orderBy('uid', 'desc')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
if (count($pending) === 0) {
|
||||
flg("邮件UID的详情,已拉取完毕。", false);
|
||||
break;
|
||||
}
|
||||
|
||||
flg("处理下一批 " . count($pending) . " 条 …", false);
|
||||
|
||||
foreach ($pending as $row) {
|
||||
$uid = $row->uid;
|
||||
|
||||
// 包裹整行处理在 try/catch 中,任何异常都不会中断循环
|
||||
try {
|
||||
flg("---- 开始处理 UID={$uid} ----", false);
|
||||
|
||||
// 1. fetch overview
|
||||
$ov = @imap_fetch_overview($inbox, $uid, FT_UID)[0] ?? null;
|
||||
if (!$ov) {
|
||||
$err = imap_last_error() ?: '未知错误';
|
||||
flg("UID={$uid} overview 失败:{$err}");
|
||||
continue;
|
||||
}
|
||||
$subject = mb_convert_encoding($ov->subject ?? '', 'UTF-8', 'GBK,UTF-8');
|
||||
$from = $ov->from ?? '';
|
||||
$date = $ov->date ?? '';
|
||||
|
||||
flg("UID={$uid} overview 成功,subject={$subject}", false);
|
||||
|
||||
// 2. fetch body
|
||||
$body = $this->getMailBody($inbox, $uid, true);
|
||||
flg("UID={$uid} body 长度:" . strlen($body), false);
|
||||
|
||||
// 3. sanitize & insert
|
||||
$body = $this->sanitizeUtf8($body);
|
||||
$now = time();
|
||||
|
||||
try {
|
||||
Db::table('emails')->insert([
|
||||
'uid' => $uid,
|
||||
'subject' => $subject,
|
||||
'from' => $from,
|
||||
'date' => $date,
|
||||
'body' => $body,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
flg("UID={$uid} 写入 emails 成功", false);
|
||||
} catch (\Throwable $e) {
|
||||
flg("UID={$uid} 写入失败:{$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. 标记已处理
|
||||
try {
|
||||
Db::table('email_uids')
|
||||
->where('uid', $uid)
|
||||
->update([
|
||||
'status' => 1,
|
||||
'processed_at' => $now,
|
||||
]);
|
||||
flg("UID={$uid} 标记为已处理", false);
|
||||
} catch (\Throwable $e) {
|
||||
flg("UID={$uid} 标记状态失败:{$e->getMessage()}", false);
|
||||
// 即使更新 status 失败,也继续下一条
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 捕获任何非预期错误
|
||||
flg("UID={$uid} 处理异常:{$e->getMessage()}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 批次结束后稍作休眠,防止服务器过载
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
imap_close($inbox);
|
||||
flg("✔ 新邮件详情拉取完成 ...", false);
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
flg(dpr($th));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件清洗处理
|
||||
*/
|
||||
public function process()
|
||||
{
|
||||
try {
|
||||
$page = 0;
|
||||
|
||||
// 明确定义 parsed_contacts 表里要插入的所有列(顺序无所谓,但要全部包含)
|
||||
$fields = [
|
||||
'email_id',
|
||||
'date',
|
||||
'email',
|
||||
'name',
|
||||
'tel',
|
||||
'company',
|
||||
'country',
|
||||
'form_url',
|
||||
'message',
|
||||
'ip',
|
||||
'iplocation',
|
||||
'ilang',
|
||||
'mailto',
|
||||
'navigator_language',
|
||||
'navigator_platform',
|
||||
'screen_width',
|
||||
'screen_height',
|
||||
'ucountry',
|
||||
];
|
||||
|
||||
$maxEmailId = Db::table('email_parsed_result')->max('email_id');
|
||||
|
||||
while (true) {
|
||||
$rows = Db::table('emails')
|
||||
->where('status', 0)
|
||||
->where('id', '>', $maxEmailId)
|
||||
->orderBy('id', 'asc')
|
||||
->limit($this->batch_size)
|
||||
->offset($page * $this->batch_size)
|
||||
->get();
|
||||
|
||||
if ($rows->isEmpty()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$toInsert = [];
|
||||
foreach ($rows as $row) {
|
||||
$parsed = $this->parseHtml($row->body);
|
||||
if ($parsed === null) {
|
||||
flg(" ↳ [{$row->id}] skip: not a contact-form HTML", false);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 严格按 $fields 顺序填充每条记录,缺失的字段补 null
|
||||
$record = [];
|
||||
foreach ($fields as $col) {
|
||||
if ($col === 'email_id') {
|
||||
$record[$col] = $row->id;
|
||||
} else {
|
||||
$record[$col] = $parsed[$col] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
$toInsert[] = $record;
|
||||
}
|
||||
|
||||
if (!empty($toInsert)) {
|
||||
Db::table('email_parsed_result')->insert($toInsert);
|
||||
|
||||
$emailId = array_column($toInsert, 'email_id');
|
||||
Db::table('emails')->whereIn('id', $emailId)->update(['status' => 1]);
|
||||
|
||||
flg("Inserted batch #" . ($page + 1) . ", count: " . count($toInsert), false);
|
||||
}
|
||||
|
||||
$page++;
|
||||
}
|
||||
|
||||
flg("✔ 邮件清洗完毕,等待分配 ...", false);
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
flg(dpr($th));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\utils\email;
|
||||
|
||||
use app\common\model\InquiryLog;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
use support\Db;
|
||||
use support\Log;
|
||||
|
||||
class MailSend
|
||||
{
|
||||
/**
|
||||
* 发送邮件
|
||||
*
|
||||
* @param string|array $to 收件人邮箱(单个字符串或多个数组)
|
||||
* @param string $subject 邮件主题
|
||||
* @param string $body 邮件内容(HTML)
|
||||
* @param array $attachments 附件路径数组 [['path' => '/path/file', 'name' => '文件名']]
|
||||
* @return bool
|
||||
*/
|
||||
public static function send($to, $subject, $body, $attachments = [])
|
||||
{
|
||||
$mail = new PHPMailer(true);
|
||||
|
||||
try {
|
||||
// 服务器配置
|
||||
$mail->isSMTP();
|
||||
$mail->Host = config('imap.smtp_xyh.host'); // SMTP服务器
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = config('imap.smtp_xyh.username'); // 邮箱账号
|
||||
$mail->Password = config('imap.smtp_xyh.password'); // 邮箱密码/授权码
|
||||
$mail->SMTPSecure = config('imap.smtp_xyh.encryption'); // 加密方式 tls/ssl
|
||||
$mail->Port = config('imap.smtp_xyh.port'); // 端口
|
||||
|
||||
// 发件人
|
||||
$mail->setFrom(config('imap.smtp_xyh.from_address'), config('imap.smtp_xyh.from_name'));
|
||||
|
||||
// 收件人(支持单个或多个)
|
||||
if (is_string($to)) {
|
||||
$mail->addAddress($to);
|
||||
} elseif (is_array($to)) {
|
||||
foreach ($to as $email) {
|
||||
$mail->addAddress($email);
|
||||
}
|
||||
}
|
||||
|
||||
// 附件
|
||||
foreach ($attachments as $file) {
|
||||
$mail->addAttachment($file['path'], $file['name'] ?? '');
|
||||
}
|
||||
|
||||
// 内容
|
||||
$mail->isHTML(true);
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->Encoding = 'base64';
|
||||
$mail->Subject = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n");
|
||||
$mail->Body = $body;
|
||||
// $mail->AltBody = strip_tags($body); // 纯文本备用
|
||||
// $mail->AltBody = $altbody;
|
||||
|
||||
$mail->send();
|
||||
return true;
|
||||
} catch (Exception $e) {
|
||||
Log::error("邮件发送失败: " . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>客户信息 - {{order_no}}</title>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
color: #343a40;
|
||||
}
|
||||
|
||||
.email-container {
|
||||
max-width: 800px;
|
||||
width: 100%;
|
||||
background: white;
|
||||
border-radius: 15px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
border-top: 5px solid #4361ee;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
background: linear-gradient(120deg, #4361ee, #3a0ca3);
|
||||
color: white;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 15px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.1rem;
|
||||
opacity: 0.9;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sn-badge {
|
||||
background: #ff9e00;
|
||||
color: white;
|
||||
padding: 6px 15px;
|
||||
border-radius: 20px;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 25px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
border-left: 4px solid #4361ee;
|
||||
}
|
||||
|
||||
.info-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1rem;
|
||||
color: #6c757d;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-title i {
|
||||
margin-right: 10px;
|
||||
color: #4361ee;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.status-container {
|
||||
background: #e7f5ff;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
margin: 30px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px dashed #4dabf7;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8px 20px;
|
||||
border-radius: 30px;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.status-badge.copper {
|
||||
background: #cd7f32;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: #ffd8a8;
|
||||
color: #e67700;
|
||||
}
|
||||
|
||||
.details-section {
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
padding: 25px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.3rem;
|
||||
margin-bottom: 20px;
|
||||
color: #212529;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 2px solid #dee2e6;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.section-title i {
|
||||
margin-right: 10px;
|
||||
color: #4361ee;
|
||||
}
|
||||
|
||||
.details-content {
|
||||
line-height: 1.8;
|
||||
color: #495057;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
background: #f1f3f5;
|
||||
padding: 25px;
|
||||
text-align: center;
|
||||
color: #868e96;
|
||||
font-size: 0.9rem;
|
||||
border-top: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.footer-links a {
|
||||
color: #495057;
|
||||
text-decoration: none;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.footer-links a:hover {
|
||||
color: #4361ee;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.email-header {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.status-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
.status-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.left{
|
||||
width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.right{
|
||||
width: 100%;
|
||||
}
|
||||
.copper{
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
<div class="header-content">
|
||||
<div class="logo">
|
||||
<i class="fas fa-user-tie"></i>
|
||||
</div>
|
||||
<h1>客户信息详情</h1>
|
||||
<!--<p class="subtitle">此邮件包含客户详细信息及业务状态,请及时处理</p>-->
|
||||
<div class="sn-badge">编号: {{order_no}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-body">
|
||||
<div class="info-grid">
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-user"></i> 客户名称
|
||||
</div>
|
||||
<div class="card-value">{{contacts}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-phone"></i> 电话
|
||||
</div>
|
||||
<div class="card-value">{{tel}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-globe"></i> 国家/地区
|
||||
</div>
|
||||
<div class="card-value">{{country}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-chart-line"></i> 渠道
|
||||
</div>
|
||||
<div class="card-value">{{channel}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-envelope"></i> 邮箱
|
||||
</div>
|
||||
<div class="card-value">{{email}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fab fa-whatsapp"></i> WhatsApp
|
||||
</div>
|
||||
<div class="card-value">{{whatsapp}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-user-plus"></i> 发布人
|
||||
</div>
|
||||
<div class="card-value">{{enter_user_id}}</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-user-check"></i> 跟踪人
|
||||
</div>
|
||||
<div class="card-value">{{recipient}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="status-container">
|
||||
<div class="left">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-history"></i> 录单时间
|
||||
</div>
|
||||
<div class="card-value">{{r_time}}</div>
|
||||
</div>
|
||||
|
||||
<div class="right">
|
||||
<div class="card-title">
|
||||
<i class="fas fa-medal"></i> 商机等级
|
||||
</div>
|
||||
<div class="status-badge copper">{{quality}}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-section">
|
||||
<h3 class="section-title">
|
||||
<i class="fas fa-file-alt"></i> 详情内容
|
||||
</h3>
|
||||
<div class="details-content">
|
||||
{{content}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="email-footer">
|
||||
<p>© 2025 客户关系管理系统 | 企业客户管理平台</p>
|
||||
<div class="footer-links">
|
||||
<a href="https://www.hnxinyouhe.com"><i class="fas fa-globe"></i> 公司官网</a>
|
||||
<a href="https://www.hnxinyouhe.com"><i class="fas fa-headset"></i> 支持中心</a>
|
||||
<a href="https://www.hnxinyouhe.com"><i class="fas fa-lock"></i> 隐私政策</a>
|
||||
</div>
|
||||
<p style="margin-top: 15px;">此邮件为系统自动生成,请勿直接回复</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 添加简单的交互效果
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const infoCards = document.querySelectorAll('.info-card');
|
||||
|
||||
infoCards.forEach(card => {
|
||||
card.addEventListener('click', function() {
|
||||
this.classList.toggle('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,14 +2,17 @@
|
||||
|
||||
namespace app\controller;
|
||||
|
||||
use app\common\model\InquiryLog;
|
||||
use app\common\model\User;
|
||||
use app\common\model\UserRoles;
|
||||
use app\common\model\UserSystems;
|
||||
use app\common\utils\email\MailSend;
|
||||
use app\common\utils\scaffold\Generate;
|
||||
use support\Request;
|
||||
use support\Db;
|
||||
use support\Cache;
|
||||
use app\inquiry\service\InquiryLogService;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class IndexController
|
||||
{
|
||||
@@ -77,7 +80,7 @@ class IndexController
|
||||
// ],
|
||||
'model' => [
|
||||
'module' => 'common',
|
||||
'name' => 'crm_customer'
|
||||
'name' => 'inquiry_channel'
|
||||
],
|
||||
// 'service' => [
|
||||
// 'module' => 'inquiry',
|
||||
@@ -91,7 +94,7 @@ class IndexController
|
||||
// 'module' => 'inquiry',
|
||||
// 'name' => 'country'
|
||||
// ],
|
||||
'table' => 'crm_customer'
|
||||
'table' => 'inquiry_channel'
|
||||
];
|
||||
|
||||
$result = (new Generate($options))->doGenerate();
|
||||
@@ -101,23 +104,10 @@ class IndexController
|
||||
|
||||
public function test()
|
||||
{
|
||||
// $res = ['id'=>25714];
|
||||
// \Webman\RedisQueue\Redis::send('inquiry-to-crm', $res);
|
||||
|
||||
// $res = [
|
||||
// 'id'=>25717,
|
||||
// 'category'=>'inquiry',
|
||||
// 'scene'=>'inquiry-person-allocation',
|
||||
// 'receive_type'=>'person'
|
||||
// ];
|
||||
$data = MailSend::getContent(22);
|
||||
MailSend::send('mzl@hnxinyouhe.com', '【XYH】新的客户线索', replaceTpl($data));
|
||||
|
||||
// $res = [
|
||||
// 'id'=>1,
|
||||
// 'category'=>'crm',
|
||||
// 'scene'=>'crm-person-follow-up',
|
||||
// 'receive_type'=>'person'
|
||||
// ];
|
||||
// \Webman\RedisQueue\Redis::send('feishu-message', $res);
|
||||
return 'success';
|
||||
return 22;
|
||||
}
|
||||
}
|
||||
+115
-4
@@ -2,7 +2,9 @@
|
||||
/**
|
||||
* Here is your custom functions.
|
||||
*/
|
||||
|
||||
|
||||
use Webman\RedisQueue\Redis as RedisQueueRedis;
|
||||
|
||||
/**
|
||||
* 标准化账号参数
|
||||
*
|
||||
@@ -70,17 +72,126 @@ if (!function_exists('getConfig')) {
|
||||
* 询单号生成
|
||||
*/
|
||||
if (!function_exists('orderNum')) {
|
||||
function orderNum() {
|
||||
function orderNum()
|
||||
{
|
||||
return "SN".date('Ymd') . str_pad(mt_rand(1, 99999), 5, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 调试用
|
||||
* 调试用(console)
|
||||
*/
|
||||
if (!function_exists('dpr')) {
|
||||
function dpr(\Throwable $e)
|
||||
{
|
||||
var_dump("=== === 报错打印 === ===", "文件:".$e->getFile(), "行数:".$e->getLine(), "详情:".$e->getMessage(), "=== === 报错打印 === ===");
|
||||
$contents = "=== === 报错打印 === ===\n";
|
||||
$contents .= "文件:".$e->getFile()."\n";
|
||||
$contents .= "行数:".$e->getLine()."\n";
|
||||
$contents .= "详情:".$e->getMessage()."\n";
|
||||
$contents .= "=== === 报错打印 === ===\n";
|
||||
var_dump($contents);
|
||||
return $contents;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试用(file)
|
||||
*/
|
||||
if (!function_exists('flg')) {
|
||||
function flg($eventData = '', $isFs = true, $fileName = 'flg', $fileCatalog = 'flg')
|
||||
{
|
||||
if ($eventData == '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (is_array($eventData) || is_object($eventData)) {
|
||||
$eventData = json_encode($eventData);
|
||||
}
|
||||
|
||||
$fileCatalog = substr(__FILE__, 0, strpos(__FILE__, 'app/')) . "runtime/" . $fileCatalog;
|
||||
if (!is_dir($fileCatalog)) {
|
||||
mkdir($fileCatalog, 0777, true);
|
||||
exec('chown -R nginx:nginx ' . $fileCatalog);
|
||||
}
|
||||
|
||||
$fileName = $fileName . '-' . date('Ymd') . '.log';
|
||||
|
||||
if ($isFs) {
|
||||
$data = [
|
||||
'content' => $eventData,
|
||||
'time' => date('Y-m-d H:i:s', time())
|
||||
];
|
||||
RedisQueueRedis::send("server", $data);
|
||||
}
|
||||
|
||||
return file_put_contents($fileCatalog . "/" . $fileName, date("Y-m-d H:i:s", time()) . "::" . $eventData . "\n", FILE_APPEND);
|
||||
}
|
||||
}
|
||||
|
||||
/***
|
||||
* 发送飞书文本消息
|
||||
*/
|
||||
if (!function_exists('sendFeishuTextMessage')) {
|
||||
function sendFeishuTextMessage($webhookUrl, $message) {
|
||||
$data = [
|
||||
'msg_type' => 'text',
|
||||
'content' => [
|
||||
'text' => $message
|
||||
]
|
||||
];
|
||||
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $webhookUrl);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换模板
|
||||
*/
|
||||
if (!function_exists('replaceTpl')) {
|
||||
function replaceTpl(array $data = [], string $templatePath = 'common/utils/scaffold/templates/Email.stub')
|
||||
{
|
||||
if (!$data || !$templatePath) return '';
|
||||
$templatePath = app_path($templatePath);
|
||||
$content = file_get_contents($templatePath);
|
||||
foreach ($data as $key => $value) {
|
||||
$content = str_replace('{{'.$key.'}}', $value, $content);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换模板
|
||||
*/
|
||||
if (!function_exists('InquiryFormat')) {
|
||||
function InquiryFormat(array $data = [])
|
||||
{
|
||||
if (!$data) return '';
|
||||
$content = '';
|
||||
// foreach ($data as $key => $value) {
|
||||
// $content .= $key . ':' . $value . "\r\n";
|
||||
// }
|
||||
$content .= '编号:'.$data['order_no'] . "\r\n";
|
||||
$content .= '询单质量:'.$data['quality'] . "\r\n";
|
||||
$content .= '渠道:'.$data['channel'] . "\r\n";
|
||||
$content .= '邮箱:'.$data['email'] . "\r\n";
|
||||
$content .= '电话:'.$data['tel'] . "\r\n";
|
||||
$content .= 'WhatsApp:'.$data['whatsapp'] . "\r\n";
|
||||
$content .= '国家:'.$data['country'] . "\r\n";
|
||||
$content .= '联系人:'.$data['contacts'] . "\r\n";
|
||||
$content .= '录单人:'.$data['enter_user_id'] . "\r\n";
|
||||
$content .= '录单时间:'.$data['r_time'] . "\r\n";
|
||||
$content .= '内容:'.$data['content'];
|
||||
return $content;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
namespace app\inquiry\controller;
|
||||
|
||||
use app\common\controller\BaseController;
|
||||
use app\common\model\Country;
|
||||
use app\common\model\InquiryChannel;
|
||||
use app\common\model\InquiryLog;
|
||||
use app\common\traits\crud\CrudControllerTrait;
|
||||
use app\inquiry\service\CountryService;
|
||||
use support\Request;
|
||||
use support\Db;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class StatisticsController extends BaseController
|
||||
{
|
||||
use CrudControllerTrait;
|
||||
|
||||
protected $service;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->service = new CountryService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表页数据格式化
|
||||
*
|
||||
* @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;
|
||||
// }
|
||||
|
||||
public function whole()
|
||||
{
|
||||
$totals = InquiryLog::count('id');
|
||||
$channelCount = InquiryChannel::count('id');
|
||||
$todayCount = InquiryLog::where('created_at', '>=', strtotime(date('Y-m-d')))->count('id');
|
||||
$releaseCount = InquiryLog::groupBy('enter_user_id')->count('id');
|
||||
$countryCount = Country::count('id');
|
||||
$trackCount = InquiryLog::groupBy('recipient')->count('id');
|
||||
$data = [
|
||||
'totals' => $totals,
|
||||
'channelCount' => $channelCount,
|
||||
'todayCount' => $todayCount,
|
||||
'releaseCount' => $releaseCount,
|
||||
'countryCount' => $countryCount,
|
||||
'trackCount' => $trackCount
|
||||
];
|
||||
return $this->success($data);
|
||||
}
|
||||
|
||||
// 各渠道计数
|
||||
public function channelCount()
|
||||
{
|
||||
$inquiryChannelList = InquiryChannel::all(['id', 'name']);
|
||||
$channels = [];
|
||||
foreach ($inquiryChannelList as $key => $val) {
|
||||
$channels[$val->id] = $val->name;
|
||||
}
|
||||
|
||||
$list = InquiryLog::select('channel', Db::raw('COUNT(*) as totals'))
|
||||
->groupBy('channel')
|
||||
->get()->toArray();
|
||||
|
||||
$channelCounts = [];
|
||||
foreach ($list as $vo) {
|
||||
$channelCounts[] = [
|
||||
"name" => $channels[$vo["channel"]],
|
||||
"value" => $vo["totals"]
|
||||
];
|
||||
}
|
||||
return $this->success($channelCounts);
|
||||
}
|
||||
|
||||
// 各国家计数
|
||||
public function countryCount()
|
||||
{
|
||||
$list = InquiryLog::with(["countryInfo"])
|
||||
->select('country', Db::raw('COUNT(*) as totals'))
|
||||
->groupBy('country')
|
||||
->get();
|
||||
$arr = [];
|
||||
foreach ($list as $val) {
|
||||
$arr[] = [
|
||||
"value" => $val->totals,
|
||||
"name" => $val->countryInfo->cn_addr
|
||||
];
|
||||
}
|
||||
return $this->success($arr);
|
||||
}
|
||||
|
||||
// 跟单人计数
|
||||
public function trackCount()
|
||||
{
|
||||
$list = InquiryLog::with(["recipientInfo"])
|
||||
->where("recipient", ">", 0)
|
||||
->whereNotIn('recipient', [1,5,10,11,13,15,16,17,19,74,75,78,141,147])
|
||||
->select('recipient', Db::raw('COUNT(*) as totals'))
|
||||
->groupBy('recipient')
|
||||
->get();
|
||||
$optionType = [];
|
||||
$counts = [];
|
||||
foreach ($list as $val) {
|
||||
if (isset($val->recipientInfo) && $val->recipientInfo) {
|
||||
$optionType[] = $val->recipientInfo->name;
|
||||
$counts[] = $val->totals;
|
||||
}
|
||||
}
|
||||
$arr = [
|
||||
"optionType" => $optionType,
|
||||
"counts" => $counts
|
||||
];
|
||||
return $this->success($arr);
|
||||
}
|
||||
|
||||
// 每日录单数
|
||||
public function dayCount()
|
||||
{
|
||||
$list = Db::table('inquiry_log')
|
||||
->where('created_at', '>=', Carbon::now()->subDay(7)->timestamp)
|
||||
->select(
|
||||
Db::raw('DATE(FROM_UNIXTIME(created_at)) as date'),
|
||||
Db::raw('COUNT(*) as order_count')
|
||||
)
|
||||
->groupBy('date')
|
||||
->pluck('order_count', 'date')->toArray();
|
||||
$optionType = [];
|
||||
$counts = [];
|
||||
foreach ($list as $key=>$val) {
|
||||
$optionType[] = $key;
|
||||
$counts[] = $val;
|
||||
}
|
||||
$arr = [
|
||||
"optionType" => $optionType,
|
||||
"counts" => $counts
|
||||
];
|
||||
return $this->success($arr);
|
||||
}
|
||||
|
||||
public function inquiryCount(Request $request)
|
||||
{
|
||||
$companyId = $request->get('company_id') ?? 0;
|
||||
|
||||
// 1. 计算时间范围
|
||||
$now = Carbon::now();
|
||||
$sixMonthsAgo = $now->copy()->subMonths(5)->startOfMonth(); // 包含6个月
|
||||
|
||||
// 2. 获取关联渠道ID
|
||||
$channelIds = $this->getChannelIdsByCompany($companyId);
|
||||
|
||||
if (empty($channelIds)) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
// 3. 执行查询
|
||||
$list = InquiryLog::select([
|
||||
DB::raw("DATE_FORMAT(FROM_UNIXTIME(r_time), '%Y-%m') AS month"),
|
||||
DB::raw('COUNT(*) AS total_count')
|
||||
])
|
||||
->whereIn('channel', $channelIds)
|
||||
->where('r_time', '>', 0)
|
||||
->where('r_time', '>=', $sixMonthsAgo->timestamp) // 添加时间限制
|
||||
->groupBy('month')
|
||||
->orderBy('month')
|
||||
->get();
|
||||
|
||||
$optionType = [];
|
||||
$counts = [];
|
||||
foreach ($list as $item) {
|
||||
$optionType[] = $item->month;
|
||||
$counts[] = $item->total_count;
|
||||
}
|
||||
$arr = [
|
||||
"optionType" => $optionType,
|
||||
"counts" => $counts
|
||||
];
|
||||
return $this->success($arr);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公司与渠道的映射
|
||||
*/
|
||||
private function getChannelIdsByCompany($companyId)
|
||||
{
|
||||
$data = [
|
||||
0 => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
|
||||
1 => [1, 2, 3, 4, 5, 6, 7, 8],
|
||||
3 => [9, 10, 11]
|
||||
];
|
||||
return $data[$companyId] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -100,7 +100,7 @@ class InquiryLogService
|
||||
$config['with']['countryInfo'][] = 'cn_addr';
|
||||
$config['with']['countryInfo'][] = 'steering';
|
||||
$config['with']['enter'][] = 'name';
|
||||
$config['with']['recipient'][] = 'name';
|
||||
$config['with']['recipientInfo'][] = 'name';
|
||||
$config['with']['channelInfo'][] = 'name';
|
||||
break;
|
||||
case 'show':
|
||||
@@ -215,7 +215,7 @@ class InquiryLogService
|
||||
$config['with']['countryInfo'][] = 'cn_addr';
|
||||
$config['with']['countryInfo'][] = 'steering';
|
||||
$config['with']['enter'][] = 'name';
|
||||
$config['with']['recipient'][] = 'name';
|
||||
$config['with']['recipientInfo'][] = 'name';
|
||||
$config['with']['channelInfo'][] = 'name';
|
||||
$config['orWhere'][] = ['id', '=', $id];
|
||||
$config['orWhere'][] = ['order_no', '=', $id];
|
||||
@@ -239,4 +239,37 @@ class InquiryLogService
|
||||
];
|
||||
return $quality[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取内容(发送邮箱用)
|
||||
* @param int $id
|
||||
* @return array
|
||||
*/
|
||||
public function getContent(int $id)
|
||||
{
|
||||
$relation = ['enter', 'recipientInfo', 'channelInfo', 'countryInfo'];
|
||||
$info = InquiryLog::with($relation)->find($id);
|
||||
|
||||
$enterName = $info->enter ? $info->enter->name : ' - ';
|
||||
$recipientName = $info->recipientInfo ? $info->recipientInfo->name : ' - ';
|
||||
$channelName = $info->channelInfo ? $info->channelInfo->name : ' - ';
|
||||
$countryName = $info->countryInfo ? $info->countryInfo->cn_addr : ' - ';
|
||||
|
||||
$data = [
|
||||
'order_no' => $info->order_no && $info->order_no != '' ? $info->order_no : ' - ',
|
||||
'enter_user_id' => $enterName,
|
||||
'recipient' => $recipientName,
|
||||
'quality' => (new \app\inquiry\service\InquiryLogService())->qualityName($info->quality),
|
||||
'channel' => $channelName,
|
||||
'email' => $info->email && $info->email != '' ? $info->email : ' - ',
|
||||
'tel' => $info->tel && $info->tel != '' ? $info->tel : ' - ',
|
||||
'whatsapp' => $info->whatsapp && $info->whatsapp != '' ? $info->whatsapp : ' - ',
|
||||
'country' => $countryName,
|
||||
'contacts' => $info->contacts && $info->contacts != '' ? $info->contacts : ' - ',
|
||||
'content' => $info->content && $info->content != '' ? $info->content : ' - ',
|
||||
'r_time' => $info->r_time ? date('Y-m-d H:i:s', $info->r_time) : ' - ',
|
||||
't_time' => $info->t_time ? date('Y-m-d H:i:s', $info->t_time) : ' - '
|
||||
];
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace app\process;
|
||||
|
||||
use app\crm\model\Customer;
|
||||
use support\Log;
|
||||
use Workerman\Crontab\Crontab;
|
||||
use app\common\utils\email\Emails as EmailsUtils;
|
||||
|
||||
class Emails
|
||||
{
|
||||
|
||||
public function onWorkerStart()
|
||||
{
|
||||
// 拉取新邮件
|
||||
new Crontab('0 */30 * * * *', function(){
|
||||
$email = new EmailsUtils();
|
||||
$email->uids();
|
||||
});
|
||||
|
||||
// 邮件详情
|
||||
new Crontab('0 */35 * * * *', function(){
|
||||
$email = new EmailsUtils();
|
||||
$email->details();
|
||||
});
|
||||
|
||||
// 邮件处理
|
||||
new Crontab('0 */40 * * * *', function(){
|
||||
$email = new EmailsUtils();
|
||||
$email->process();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace app\queue\redis;
|
||||
|
||||
use Webman\RedisQueue\Consumer;
|
||||
use app\inquiry\service\InquiryLogService;
|
||||
use app\common\utils\email\MailSend;
|
||||
|
||||
class EmailMessage implements Consumer
|
||||
{
|
||||
// 要消费的队列名
|
||||
public $queue = 'email-message';
|
||||
|
||||
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
|
||||
public $connection = 'default';
|
||||
|
||||
// 消费
|
||||
public function consume($data)
|
||||
{
|
||||
try {
|
||||
// 询单信息
|
||||
$inquiryLogService = new InquiryLogService();
|
||||
$data = $inquiryLogService->getContent($data['id']);
|
||||
|
||||
MailSend::send($data['email'], '【XYH】新的客户线索', replaceTpl($data));
|
||||
} catch (\Throwable $th) {
|
||||
throw $th;
|
||||
}
|
||||
}
|
||||
|
||||
// 消费失败回调
|
||||
/*
|
||||
$package = [
|
||||
'id' => 1357277951, // 消息ID
|
||||
'time' => 1709170510, // 消息时间
|
||||
'delay' => 0, // 延迟时间
|
||||
'attempts' => 2, // 消费次数
|
||||
'queue' => 'send-mail', // 队列名
|
||||
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
|
||||
'max_attempts' => 5, // 最大重试次数
|
||||
'error' => '错误信息' // 错误信息
|
||||
]
|
||||
*/
|
||||
public function onConsumeFailure(\Throwable $e, $package)
|
||||
{
|
||||
echo "consume failure\n";
|
||||
echo $e->getMessage() . "\n";
|
||||
// 无需反序列化
|
||||
var_export($package);
|
||||
}
|
||||
}
|
||||
@@ -29,19 +29,20 @@ class InquiryToCrm implements Consumer
|
||||
// 获取用户信息
|
||||
$recipient = 0;
|
||||
$corpId = 0;
|
||||
$uinfo = null;
|
||||
if ($info->recipient) {
|
||||
$recipient = $info->recipient;
|
||||
$uinfo = User::where('id', $info->recipient)->first();
|
||||
if ($uinfo) {
|
||||
$corpId = $uinfo->corp_id;
|
||||
}
|
||||
// 消息推送参数 分配
|
||||
$scene = 'inquiry-person-allocation';
|
||||
$receiveType = 'person';
|
||||
// // 消息推送参数 分配
|
||||
// $scene = 'inquiry-person-allocation';
|
||||
// $receiveType = 'person';
|
||||
} else {
|
||||
// 消息推送参数 发布
|
||||
$scene = 'inquiry-group-send';
|
||||
$receiveType = 'group';
|
||||
// // 消息推送参数 发布
|
||||
// $scene = 'inquiry-group-send';
|
||||
// $receiveType = 'group';
|
||||
}
|
||||
// 同步客户数据
|
||||
$crmCustomer = new CrmCustomer();
|
||||
@@ -65,14 +66,17 @@ class InquiryToCrm implements Consumer
|
||||
throw new BusinessException('同步失败~');
|
||||
}
|
||||
|
||||
// 发送飞书消息
|
||||
$options = [
|
||||
'id' => $info->id,
|
||||
'category' => 'inquiry',
|
||||
'scene' => $scene,
|
||||
'receive_type' => $receiveType
|
||||
];
|
||||
\Webman\RedisQueue\Redis::send('feishu-message', $options);
|
||||
// 发送邮件消息
|
||||
if ($uinfo && $uinfo->email) {
|
||||
$options = [
|
||||
'id' => $info->id,
|
||||
'email' => $uinfo->email,
|
||||
// 'category' => 'inquiry',
|
||||
// 'scene' => $scene,
|
||||
// 'receive_type' => $receiveType
|
||||
];
|
||||
\Webman\RedisQueue\Redis::send('email-message', $options);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $th) {
|
||||
throw $th;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace app\queue\redis;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use support\exception\BusinessException;
|
||||
use Webman\RedisQueue\Consumer;
|
||||
|
||||
class Server implements Consumer
|
||||
{
|
||||
// 要消费的队列名
|
||||
public $queue = 'server';
|
||||
|
||||
// 连接名,对应 plugin/webman/redis-queue/redis.php 里的连接`
|
||||
public $connection = 'default';
|
||||
|
||||
// 消费
|
||||
public function consume($data)
|
||||
{
|
||||
try {
|
||||
$webhookUrl = 'https://open.feishu.cn/open-apis/bot/v2/hook/806c2e9d-6ca2-4345-a459-69bb2651706b';
|
||||
$message = "====== ======\n";
|
||||
$message .= $data['content']."\n";
|
||||
$message .= "时间:".date('Y-m-d H:i:s', time())."\n";
|
||||
$message .= "====== ======";
|
||||
sendFeishuTextMessage($webhookUrl, $message);
|
||||
return true;
|
||||
} catch (\Throwable $th) {
|
||||
var_dump('4 :'.$th->getFile()."|".$th->getLine()."|".$th->getMessage());
|
||||
throw new BusinessException($th->getFile()."|".$th->getLine()."|".$th->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// 消费失败回调
|
||||
/*
|
||||
$package = [
|
||||
'id' => 1357277951, // 消息ID
|
||||
'time' => 1709170510, // 消息时间
|
||||
'delay' => 0, // 延迟时间
|
||||
'attempts' => 2, // 消费次数
|
||||
'queue' => 'send-mail', // 队列名
|
||||
'data' => ['to' => 'tom@gmail.com', 'content' => 'hello'], // 消息内容
|
||||
'max_attempts' => 5, // 最大重试次数
|
||||
'error' => '错误信息' // 错误信息
|
||||
]
|
||||
*/
|
||||
public function onConsumeFailure(\Throwable $e, $package)
|
||||
{
|
||||
echo "consume failure\n";
|
||||
echo $e->getMessage() . "\n";
|
||||
// 无需反序列化
|
||||
var_export($package);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -44,7 +44,8 @@
|
||||
"symfony/translation": "^7.3",
|
||||
"symfony/filesystem": "^7.3",
|
||||
"workerman/crontab": "^1.0",
|
||||
"phpoffice/phpspreadsheet": "^4.5"
|
||||
"phpoffice/phpspreadsheet": "^4.5",
|
||||
"phpmailer/phpmailer": "^6.10"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-event": "For better performance. "
|
||||
|
||||
Generated
+82
-1
@@ -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": "471a31011b58e1e514bdb9c8963055ad",
|
||||
"content-hash": "505fb3d97c338ea53b787196fcc3daaa",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aliyuncs/oss-sdk-php",
|
||||
@@ -3095,6 +3095,87 @@
|
||||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpmailer/phpmailer",
|
||||
"version": "v6.10.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPMailer/PHPMailer.git",
|
||||
"reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
|
||||
"reference": "bf74d75a1fde6beaa34a0ddae2ec5fce0f72a144",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-hash": "*",
|
||||
"php": ">=5.5.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "^1.0",
|
||||
"doctrine/annotations": "^1.2.6 || ^1.13.3",
|
||||
"php-parallel-lint/php-console-highlighter": "^1.0.0",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3.2",
|
||||
"phpcompatibility/php-compatibility": "^9.3.5",
|
||||
"roave/security-advisories": "dev-latest",
|
||||
"squizlabs/php_codesniffer": "^3.7.2",
|
||||
"yoast/phpunit-polyfills": "^1.0.4"
|
||||
},
|
||||
"suggest": {
|
||||
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication",
|
||||
"ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
|
||||
"ext-openssl": "Needed for secure SMTP sending and DKIM signing",
|
||||
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication",
|
||||
"hayageek/oauth2-yahoo": "Needed for Yahoo XOAUTH2 authentication",
|
||||
"league/oauth2-google": "Needed for Google XOAUTH2 authentication",
|
||||
"psr/log": "For optional PSR-3 debug logging",
|
||||
"symfony/polyfill-mbstring": "To support UTF-8 if the Mbstring PHP extension is not enabled (^1.2)",
|
||||
"thenetworg/oauth2-azure": "Needed for Microsoft XOAUTH2 authentication"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PHPMailer\\PHPMailer\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"LGPL-2.1-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marcus Bointon",
|
||||
"email": "phpmailer@synchromedia.co.uk"
|
||||
},
|
||||
{
|
||||
"name": "Jim Jagielski",
|
||||
"email": "jimjag@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Andy Prevost",
|
||||
"email": "codeworxtech@users.sourceforge.net"
|
||||
},
|
||||
{
|
||||
"name": "Brent R. Matzelle"
|
||||
}
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/Synchro",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2025-04-24T15:19:31+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "4.5.0",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
// config/imap.php
|
||||
|
||||
return [
|
||||
// IMAP 服务器连接字符串,包含端口、协议、SSL
|
||||
'mailbox' => '{imap.feishu.cn:993/imap/ssl}INBOX',
|
||||
'username' => 'info@borderlesscar.com',
|
||||
'password' => 'ErkhUA629aocm7Bc',
|
||||
// 最多拉取多少封邮件
|
||||
'limit' => 5,
|
||||
'per_page' => 20,
|
||||
'batch_size'=> 100, // 可选:CLI 不传 -p 时使用
|
||||
|
||||
// stmp
|
||||
'smtp' => [
|
||||
'host' => 'smtp.feishu.cn',
|
||||
'port' => 465,
|
||||
'username' => 'info@borderlesscar.com',
|
||||
'password' => 'ErkhUA629aocm7Bc',
|
||||
'encryption' => 'ssl',
|
||||
'from_address' => '',
|
||||
'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】'
|
||||
],
|
||||
];
|
||||
|
||||
// docker run \
|
||||
// --name l2tp-vpn-server \
|
||||
// --env VPN_IPSEC_PSK='Xyh123456' \
|
||||
// --env VPN_USER='xinyouhe' \
|
||||
// --env VPN_PASSWORD='Xyh.2025' \
|
||||
// --restart=always \
|
||||
// --privileged \
|
||||
// -p 500:500/udp \
|
||||
// -p 4500:4500/udp \
|
||||
// -p 1701:1701/udp \
|
||||
// -d hwdsl2/ipsec-vpn-server
|
||||
|
||||
|
||||
// 拉取邮件的脚本顺序:
|
||||
// php webman emails:fetch:uid
|
||||
// php webman emails:fetch
|
||||
// php webman parse:emails
|
||||
@@ -63,4 +63,7 @@ return [
|
||||
'task' => [
|
||||
'handler' => app\process\Task::class
|
||||
],
|
||||
'email' => [
|
||||
'handler' => app\process\Emails::class
|
||||
],
|
||||
];
|
||||
|
||||
@@ -89,6 +89,15 @@ Route::group('/admin', function () {
|
||||
Route::any('/destroy', [app\inquiry\controller\InquiryLogController::class, 'destroy']);
|
||||
Route::any('/release', [app\inquiry\controller\InquiryLogController::class, 'release']);
|
||||
Route::any('/allocation', [app\inquiry\controller\InquiryLogController::class, 'allocation']);
|
||||
// 统计
|
||||
Route::group('/statistics', function () {
|
||||
Route::any('/whole', [app\inquiry\controller\StatisticsController::class, 'whole']);
|
||||
Route::any('/channel/count', [app\inquiry\controller\StatisticsController::class, 'channelCount']);
|
||||
Route::any('/country/count', [app\inquiry\controller\StatisticsController::class, 'countryCount']);
|
||||
Route::any('/track/count', [app\inquiry\controller\StatisticsController::class, 'trackCount']);
|
||||
Route::any('/day/count', [app\inquiry\controller\StatisticsController::class, 'dayCount']);
|
||||
Route::any('/inquiry/count', [app\inquiry\controller\StatisticsController::class, 'inquiryCount']);
|
||||
});
|
||||
});
|
||||
|
||||
// crm
|
||||
|
||||
Reference in New Issue
Block a user