From b2d4119e463697277da682fcf2a0887d7318fa04 Mon Sep 17 00:00:00 2001 From: mzl Date: Mon, 4 Aug 2025 14:03:57 +0800 Subject: [PATCH] Merge branch 'feature/mzl_dev' --- app/command/DomainCommand.php | 3 + app/command/EmailDetailCommand.php | 206 +++++++++ app/command/EmailParseCommand.php | 144 ++++++ app/command/EmailUidCommand.php | 185 ++++++++ app/command/FixInquiryLogOrderNo.php | 3 + app/command/TbCommand.php | 18 +- app/common/logic/AuthLogic.php | 2 +- app/common/model/InquiryLog.php | 2 +- app/common/utils/email/Emails.php | 410 ++++++++++++++++++ app/common/utils/email/MailSend.php | 69 +++ .../utils/scaffold/templates/Email.stub | 365 ++++++++++++++++ app/controller/IndexController.php | 26 +- app/crm/controller/CustomerController.php | 3 + app/functions.php | 119 ++++- .../controller/StatisticsController.php | 199 +++++++++ app/inquiry/service/InquiryLogService.php | 37 +- app/process/Emails.php | 34 ++ app/queue/redis/EmailMessage.php | 51 +++ app/queue/redis/InquiryToCrm.php | 32 +- app/queue/redis/Server.php | 55 +++ composer.json | 3 +- composer.lock | 83 +++- config/imap.php | 52 +++ config/process.php | 3 + config/route.php | 9 + 25 files changed, 2066 insertions(+), 47 deletions(-) create mode 100644 app/command/EmailDetailCommand.php create mode 100644 app/command/EmailParseCommand.php create mode 100644 app/command/EmailUidCommand.php create mode 100644 app/common/utils/email/Emails.php create mode 100644 app/common/utils/email/MailSend.php create mode 100644 app/common/utils/scaffold/templates/Email.stub create mode 100644 app/inquiry/controller/StatisticsController.php create mode 100644 app/process/Emails.php create mode 100644 app/queue/redis/EmailMessage.php create mode 100644 app/queue/redis/Server.php create mode 100644 config/imap.php diff --git a/app/command/DomainCommand.php b/app/command/DomainCommand.php index d23e755..7d09da5 100644 --- a/app/command/DomainCommand.php +++ b/app/command/DomainCommand.php @@ -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'; diff --git a/app/command/EmailDetailCommand.php b/app/command/EmailDetailCommand.php new file mode 100644 index 0000000..78e5720 --- /dev/null +++ b/app/command/EmailDetailCommand.php @@ -0,0 +1,206 @@ +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("IMAP 连接失败:". imap_last_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("所有 UID 均已处理完毕。"); + 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("UID={$uid} overview 失败:{$err}"); + $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("UID={$uid} 写入 emails 成功"); + } catch (\Throwable $e) { + $output->writeln("UID={$uid} 写入失败:{$e->getMessage()}"); + $this->logError("UID={$uid} 写入失败:{$e->getMessage()}"); + continue; + } + + // 4. 标记已处理 + try { + Db::table('email_uids') + ->where('uid', $uid) + ->update([ + 'status' => 1, + 'processed_at' => $now, + ]); + $output->writeln("UID={$uid} 标记为已处理"); + } catch (\Throwable $e) { + $output->writeln("UID={$uid} 标记状态失败:{$e->getMessage()}"); + $this->logError("UID={$uid} 标记状态失败:{$e->getMessage()}"); + // 即使更新 status 失败,也继续下一条 + } + } catch (\Throwable $e) { + // 捕获任何非预期错误 + $output->writeln("UID={$uid} 处理异常:{$e->getMessage()}"); + $this->logError("UID={$uid} 处理异常:{$e->getMessage()}"); + continue; + } + } + + // 批次结束后稍作休眠,防止服务器过载 + sleep(1); + } + + imap_close($inbox); + $output->writeln("\n✅ 全部邮件处理完成。"); + 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; + } + } +} \ No newline at end of file diff --git a/app/command/EmailParseCommand.php b/app/command/EmailParseCommand.php new file mode 100644 index 0000000..3bf21f7 --- /dev/null +++ b/app/command/EmailParseCommand.php @@ -0,0 +1,144 @@ +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,找不到

Preview

则返回 null, + * 否则提取所有 Key: Value 为小写蛇形 key=>value 数组 + */ + protected function parseHtml(string $html): ?array + { + $doc = new \DOMDocument(); + libxml_use_internal_errors(true); + $doc->loadHTML('' . $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; + } +} \ No newline at end of file diff --git a/app/command/EmailUidCommand.php b/app/command/EmailUidCommand.php new file mode 100644 index 0000000..bf0e744 --- /dev/null +++ b/app/command/EmailUidCommand.php @@ -0,0 +1,185 @@ +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("第 {$attempt} 次失败:{$error}"); + + // 若出现 too many login failures 立即暂停一段时间 + if (stripos($error, 'Too many login failures') !== false) { + $output->writeln("检测到登录失败次数过多,暂停 60 秒避免封禁"); + sleep(60); + } else { + sleep($retryDelay); + } + } + + if (!$inbox) { + $output->writeln("最终仍然连接失败:" . imap_last_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("{$e->getMessage()}"); + } + } + + 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) + +*/ \ No newline at end of file diff --git a/app/command/FixInquiryLogOrderNo.php b/app/command/FixInquiryLogOrderNo.php index ed959cd..91d3c04 100644 --- a/app/command/FixInquiryLogOrderNo.php +++ b/app/command/FixInquiryLogOrderNo.php @@ -7,6 +7,9 @@ use Symfony\Component\Console\Output\OutputInterface; use support\Db; use support\Log; +/** + * 处理询单中的编号重复 + */ class FixInquiryLogOrderNo extends Command { protected static $defaultName = 'sn'; diff --git a/app/command/TbCommand.php b/app/command/TbCommand.php index d60482f..fa0a33a 100644 --- a/app/command/TbCommand.php +++ b/app/command/TbCommand.php @@ -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; } } \ No newline at end of file diff --git a/app/common/logic/AuthLogic.php b/app/common/logic/AuthLogic.php index b2a74fc..59155dc 100644 --- a/app/common/logic/AuthLogic.php +++ b/app/common/logic/AuthLogic.php @@ -93,7 +93,7 @@ class AuthLogic ->orWhere('email', $email); }) ->first(); - + $user->isLogin = true; if (!$user) { // 获取编号 $userSn = $this->getAvailableUserSn(); diff --git a/app/common/model/InquiryLog.php b/app/common/model/InquiryLog.php index ce01b4a..68926aa 100644 --- a/app/common/model/InquiryLog.php +++ b/app/common/model/InquiryLog.php @@ -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'); } diff --git a/app/common/utils/email/Emails.php b/app/common/utils/email/Emails.php new file mode 100644 index 0000000..4e87a4b --- /dev/null +++ b/app/common/utils/email/Emails.php @@ -0,0 +1,410 @@ +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,找不到

Preview

则返回 null, + * 否则提取所有 Key: Value 为小写蛇形 key=>value 数组 + */ + protected function parseHtml(string $html): ?array + { + $doc = new \DOMDocument(); + libxml_use_internal_errors(true); + $doc->loadHTML('' . $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("第 {$attempt} 次失败:{$error}"); + + // 若出现 too many login failures 立即暂停一段时间 + if (stripos($error, 'Too many login failures') !== false) { + flg("检测到登录失败次数过多,暂停 60 秒避免封禁"); + sleep(60); + } else { + sleep($retryDelay); + } + } + + if (!$inbox) { + flg("最终仍然连接失败:" . imap_last_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("{$e->getMessage()}"); + } + } + + 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("IMAP 连接失败:". imap_last_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; + } + } +} \ No newline at end of file diff --git a/app/common/utils/email/MailSend.php b/app/common/utils/email/MailSend.php new file mode 100644 index 0000000..a5742d3 --- /dev/null +++ b/app/common/utils/email/MailSend.php @@ -0,0 +1,69 @@ + '/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; + } + } +} \ No newline at end of file diff --git a/app/common/utils/scaffold/templates/Email.stub b/app/common/utils/scaffold/templates/Email.stub new file mode 100644 index 0000000..16fc580 --- /dev/null +++ b/app/common/utils/scaffold/templates/Email.stub @@ -0,0 +1,365 @@ + + + + + + 客户信息 - {{order_no}} + + + + +
+ + + + + +
+ + + + \ No newline at end of file diff --git a/app/controller/IndexController.php b/app/controller/IndexController.php index b942887..d9ac96c 100644 --- a/app/controller/IndexController.php +++ b/app/controller/IndexController.php @@ -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; } } \ No newline at end of file diff --git a/app/crm/controller/CustomerController.php b/app/crm/controller/CustomerController.php index e1e3f79..4eb3a6b 100644 --- a/app/crm/controller/CustomerController.php +++ b/app/crm/controller/CustomerController.php @@ -162,6 +162,9 @@ class CustomerController extends BaseController try { $param = $request->all(); $param['user_id'] = $request->user->uid; + if($param['user_id']==78){ + $param['user_id'] = 101; + } $param['corp_abbr'] = $request->basic_data['corp_abbr']; $result = (new CustomerService())->list($param); diff --git a/app/functions.php b/app/functions.php index 3fed6a2..45af92c 100644 --- a/app/functions.php +++ b/app/functions.php @@ -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; } } \ No newline at end of file diff --git a/app/inquiry/controller/StatisticsController.php b/app/inquiry/controller/StatisticsController.php new file mode 100644 index 0000000..0e37719 --- /dev/null +++ b/app/inquiry/controller/StatisticsController.php @@ -0,0 +1,199 @@ +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] ?? []; + } +} \ No newline at end of file diff --git a/app/inquiry/service/InquiryLogService.php b/app/inquiry/service/InquiryLogService.php index c736ceb..667e41d 100644 --- a/app/inquiry/service/InquiryLogService.php +++ b/app/inquiry/service/InquiryLogService.php @@ -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; + } } \ No newline at end of file diff --git a/app/process/Emails.php b/app/process/Emails.php new file mode 100644 index 0000000..7886fff --- /dev/null +++ b/app/process/Emails.php @@ -0,0 +1,34 @@ +uids(); + }); + + // 邮件详情 + new Crontab('0 */35 * * * *', function(){ + $email = new EmailsUtils(); + $email->details(); + }); + + // 邮件处理 + new Crontab('0 */40 * * * *', function(){ + $email = new EmailsUtils(); + $email->process(); + }); + } + +} \ No newline at end of file diff --git a/app/queue/redis/EmailMessage.php b/app/queue/redis/EmailMessage.php new file mode 100644 index 0000000..8fbeb22 --- /dev/null +++ b/app/queue/redis/EmailMessage.php @@ -0,0 +1,51 @@ +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); + } +} \ No newline at end of file diff --git a/app/queue/redis/InquiryToCrm.php b/app/queue/redis/InquiryToCrm.php index ac27813..966d4a3 100644 --- a/app/queue/redis/InquiryToCrm.php +++ b/app/queue/redis/InquiryToCrm.php @@ -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; diff --git a/app/queue/redis/Server.php b/app/queue/redis/Server.php new file mode 100644 index 0000000..d425ea7 --- /dev/null +++ b/app/queue/redis/Server.php @@ -0,0 +1,55 @@ +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); + } +} \ No newline at end of file diff --git a/composer.json b/composer.json index ec04c9f..98e3e54 100644 --- a/composer.json +++ b/composer.json @@ -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. " diff --git a/composer.lock b/composer.lock index d88df56..edf286f 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "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", diff --git a/config/imap.php b/config/imap.php new file mode 100644 index 0000000..de9d8f1 --- /dev/null +++ b/config/imap.php @@ -0,0 +1,52 @@ + '{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 \ No newline at end of file diff --git a/config/process.php b/config/process.php index 05b250f..2de2c20 100644 --- a/config/process.php +++ b/config/process.php @@ -63,4 +63,7 @@ return [ 'task' => [ 'handler' => app\process\Task::class ], + 'email' => [ + 'handler' => app\process\Emails::class + ], ]; diff --git a/config/route.php b/config/route.php index 303c4aa..5023f1f 100644 --- a/config/route.php +++ b/config/route.php @@ -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']); + }); }); });