Merge branch 'feature/mzl_dev'

This commit is contained in:
mzl
2025-08-04 14:03:57 +08:00
parent 8f74999b47
commit b2d4119e46
25 changed files with 2066 additions and 47 deletions
+1 -1
View File
@@ -93,7 +93,7 @@ class AuthLogic
->orWhere('email', $email);
})
->first();
$user->isLogin = true;
if (!$user) {
// 获取编号
$userSn = $this->getAvailableUserSn();
+1 -1
View File
@@ -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');
}
+410
View File
@@ -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;
}
}
}
+69
View File
@@ -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>