325 lines
12 KiB
PHP
325 lines
12 KiB
PHP
<?php
|
|
|
|
namespace app\common;
|
|
|
|
use think\Db;
|
|
use think\Cache;
|
|
use think\Queue;
|
|
use PHPMailer\PHPMailer\PHPMailer;
|
|
|
|
class PromotionService
|
|
{
|
|
private $logFile;
|
|
|
|
public function __construct()
|
|
{
|
|
$this->logFile = ROOT_PATH . 'runtime' . DS . 'promotion_task.log';
|
|
}
|
|
|
|
/**
|
|
* Process the next email in a promotion task (called by queue job)
|
|
*/
|
|
public function processNextEmail($taskId)
|
|
{
|
|
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
|
|
if (!$task) {
|
|
return ['done' => true, 'reason' => 'task_not_found'];
|
|
}
|
|
if ($task['state'] != 1) {
|
|
return ['done' => true, 'reason' => 'task_not_running', 'state' => $task['state']];
|
|
}
|
|
|
|
$currentHour = intval(date('G'));
|
|
if ($currentHour < $task['send_start_hour'] || $currentHour >= $task['send_end_hour']) {
|
|
$this->enqueueNextEmail($taskId, 300);
|
|
return ['done' => false, 'reason' => 'outside_send_window', 'retry_in' => 300];
|
|
}
|
|
|
|
if ($task['sent_count'] > 0 && $task['max_bounce_rate'] > 0) {
|
|
$bounceRate = ($task['bounce_count'] / $task['sent_count']) * 100;
|
|
if ($bounceRate >= $task['max_bounce_rate']) {
|
|
Db::name('promotion_task')->where('task_id', $taskId)->update([
|
|
'state' => 2,
|
|
'utime' => time(),
|
|
]);
|
|
$this->log("Task {$taskId} auto-paused: bounce rate {$bounceRate}% >= {$task['max_bounce_rate']}%");
|
|
return ['done' => true, 'reason' => 'auto_paused_bounce_rate', 'bounce_rate' => $bounceRate];
|
|
}
|
|
}
|
|
|
|
$logEntry = Db::name('promotion_email_log')
|
|
->where('task_id', $taskId)
|
|
->where('state', 0)
|
|
->order('log_id asc')
|
|
->find();
|
|
|
|
if (!$logEntry) {
|
|
Db::name('promotion_task')->where('task_id', $taskId)->update([
|
|
'state' => 3,
|
|
'utime' => time(),
|
|
]);
|
|
return ['done' => true, 'reason' => 'all_emails_processed'];
|
|
}
|
|
|
|
$expert = Db::name('expert')->where('expert_id', $logEntry['expert_id'])->find();
|
|
if (!$expert || $expert['state'] == 4 || $expert['state'] == 5) {
|
|
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
|
|
'state' => 2,
|
|
'error_msg' => 'Expert invalid or deleted (state=' . (isset($expert['state']) ? $expert['state'] : 'null') . ')',
|
|
'send_time' => time(),
|
|
]);
|
|
Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count');
|
|
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]);
|
|
$this->enqueueNextEmail($taskId, 2);
|
|
return ['done' => false, 'skipped' => $logEntry['email_to'], 'reason' => 'expert_invalid'];
|
|
}
|
|
|
|
$account = $this->pickSmtpAccountForTask($task);
|
|
if (!$account) {
|
|
$this->enqueueNextEmail($taskId, 600);
|
|
return ['done' => false, 'reason' => 'no_smtp_quota', 'retry_in' => 600];
|
|
}
|
|
|
|
$journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find();
|
|
$expertVars = $this->buildExpertVars($expert);
|
|
$journalVars = $this->buildJournalVars($journal);
|
|
$vars = array_merge($journalVars, $expertVars);
|
|
|
|
$rendered = $this->renderFromTemplate(
|
|
$task['template_id'],
|
|
$task['journal_id'],
|
|
json_encode($vars, JSON_UNESCAPED_UNICODE),
|
|
$task['style_id']
|
|
);
|
|
|
|
if ($rendered['code'] !== 0) {
|
|
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
|
|
'state' => 2,
|
|
'error_msg' => 'Template render failed: ' . $rendered['msg'],
|
|
'send_time' => time(),
|
|
]);
|
|
Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count');
|
|
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]);
|
|
$this->enqueueNextEmail($taskId, 2);
|
|
return ['done' => false, 'failed' => $logEntry['email_to'], 'reason' => 'template_error'];
|
|
}
|
|
|
|
$subject = $rendered['data']['subject'];
|
|
$body = $rendered['data']['body'];
|
|
|
|
$result = $this->doSendEmail($account, $logEntry['email_to'], $subject, $body);
|
|
|
|
$now = time();
|
|
if ($result['status'] === 1) {
|
|
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
|
|
'j_email_id' => $account['j_email_id'],
|
|
'subject' => mb_substr($subject, 0, 512),
|
|
'state' => 1,
|
|
'send_time' => $now,
|
|
]);
|
|
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
|
|
Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['ltime' => $now]);
|
|
Db::name('promotion_task')->where('task_id', $taskId)->setInc('sent_count');
|
|
} else {
|
|
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
|
|
'j_email_id' => $account['j_email_id'],
|
|
'subject' => mb_substr($subject, 0, 512),
|
|
'state' => 2,
|
|
'error_msg' => mb_substr($result['data'], 0, 512),
|
|
'send_time' => $now,
|
|
]);
|
|
Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count');
|
|
}
|
|
|
|
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => $now]);
|
|
|
|
$delay = rand(max(5, $task['min_interval']), max($task['min_interval'], $task['max_interval']));
|
|
$this->enqueueNextEmail($taskId, $delay);
|
|
|
|
return [
|
|
'done' => false,
|
|
'sent' => $result['status'] === 1,
|
|
'email' => $logEntry['email_to'],
|
|
'next_in' => $delay,
|
|
];
|
|
}
|
|
|
|
// ==================== Queue ====================
|
|
|
|
public function enqueueNextEmail($taskId, $delay = 0)
|
|
{
|
|
$jobClass = 'app\api\job\PromotionSend@fire';
|
|
$data = ['task_id' => $taskId];
|
|
|
|
if ($delay > 0) {
|
|
Queue::later($delay, $jobClass, $data, 'promotion');
|
|
} else {
|
|
Queue::push($jobClass, $data, 'promotion');
|
|
}
|
|
}
|
|
|
|
// ==================== SMTP ====================
|
|
|
|
public function pickSmtpAccountForTask($task)
|
|
{
|
|
$journalId = $task['journal_id'];
|
|
$smtpIds = $task['smtp_ids'] ? array_map('intval', explode(',', $task['smtp_ids'])) : [];
|
|
|
|
$query = Db::name('journal_email')
|
|
->where('journal_id', $journalId)
|
|
->where('state', 0);
|
|
|
|
if (!empty($smtpIds)) {
|
|
$query->where('j_email_id', 'in', $smtpIds);
|
|
}
|
|
|
|
$accounts = $query->select();
|
|
if (empty($accounts)) {
|
|
return null;
|
|
}
|
|
|
|
$best = null;
|
|
$bestRemaining = -1;
|
|
|
|
foreach ($accounts as $acc) {
|
|
$this->resetDailyCountIfNeeded($acc);
|
|
$remaining = $acc['daily_limit'] - $acc['today_sent'];
|
|
if ($remaining > 0 && $remaining > $bestRemaining) {
|
|
$best = $acc;
|
|
$bestRemaining = $remaining;
|
|
}
|
|
}
|
|
|
|
return $best;
|
|
}
|
|
|
|
public function resetDailyCountIfNeeded(&$account)
|
|
{
|
|
$todayDate = date('Y-m-d');
|
|
$cacheKey = 'smtp_reset_' . $account['j_email_id'];
|
|
$lastReset = Cache::get($cacheKey);
|
|
|
|
if ($lastReset !== $todayDate) {
|
|
Db::name('journal_email')
|
|
->where('j_email_id', $account['j_email_id'])
|
|
->update(['today_sent' => 0]);
|
|
$account['today_sent'] = 0;
|
|
Cache::set($cacheKey, $todayDate, 86400);
|
|
}
|
|
}
|
|
|
|
public function doSendEmail($account, $toEmail, $subject, $htmlContent)
|
|
{
|
|
try {
|
|
$mail = new PHPMailer(true);
|
|
$mail->isSMTP();
|
|
$mail->SMTPDebug = 0;
|
|
$mail->CharSet = 'UTF-8';
|
|
$mail->Host = $account['smtp_host'];
|
|
$mail->Port = intval($account['smtp_port']);
|
|
$mail->SMTPAuth = true;
|
|
$mail->Username = $account['smtp_user'];
|
|
$mail->Password = $account['smtp_password'];
|
|
|
|
if ($account['smtp_encryption'] === 'ssl') {
|
|
$mail->SMTPSecure = 'ssl';
|
|
} elseif ($account['smtp_encryption'] === 'tls') {
|
|
$mail->SMTPSecure = 'tls';
|
|
} else {
|
|
$mail->SMTPSecure = false;
|
|
$mail->SMTPAutoTLS = false;
|
|
}
|
|
|
|
$fromName = !empty($account['smtp_from_name']) ? $account['smtp_from_name'] : $account['smtp_user'];
|
|
$mail->setFrom($account['smtp_user'], $fromName);
|
|
$mail->addReplyTo($account['smtp_user'], $fromName);
|
|
$mail->addAddress($toEmail);
|
|
|
|
$mail->isHTML(true);
|
|
$mail->Subject = $subject;
|
|
$mail->Body = $htmlContent;
|
|
$mail->AltBody = strip_tags($htmlContent);
|
|
|
|
$mail->send();
|
|
|
|
return ['status' => 1, 'data' => 'success'];
|
|
} catch (\Exception $e) {
|
|
return ['status' => 0, 'data' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
// ==================== Template Rendering ====================
|
|
|
|
public function renderFromTemplate($templateId, $journalId, $varsJson, $styleId = 0)
|
|
{
|
|
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('journal_id', $journalId)->where('state', 0)->find();
|
|
if (!$tpl) {
|
|
return ['code' => 1, 'msg' => 'Template not found'];
|
|
}
|
|
|
|
$vars = [];
|
|
if ($varsJson) {
|
|
$decoded = json_decode($varsJson, true);
|
|
if (is_array($decoded)) $vars = $decoded;
|
|
}
|
|
|
|
$subject = $this->renderVars($tpl['subject'], $vars);
|
|
$body = $this->renderVars($tpl['body_html'], $vars);
|
|
$finalBody = $body;
|
|
|
|
if ($styleId) {
|
|
$style = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
|
|
if ($style) {
|
|
$header = $style['header_html'] ?? '';
|
|
$footer = $style['footer_html'] ?? '';
|
|
$finalBody = $header . $body . $footer;
|
|
}
|
|
}
|
|
|
|
return ['code' => 0, 'msg' => 'success', 'data' => ['subject' => $subject, 'body' => $finalBody]];
|
|
}
|
|
|
|
public function buildExpertVars($expert)
|
|
{
|
|
return [
|
|
'name' => $expert['name'] ?? '',
|
|
'email' => $expert['email'] ?? '',
|
|
'affiliation' => $expert['affiliation'] ?? '',
|
|
'field' => $expert['field'] ?? '',
|
|
];
|
|
}
|
|
|
|
public function buildJournalVars($journal)
|
|
{
|
|
if (!$journal) return [];
|
|
return [
|
|
'journal_title' => $journal['title'] ?? '',
|
|
'journal_abbr' => $journal['jabbr'] ?? '',
|
|
'journal_url' => $journal['website'] ?? '',
|
|
];
|
|
}
|
|
|
|
public function renderVars($tpl, $vars)
|
|
{
|
|
if (!is_string($tpl) || $tpl === '') return '';
|
|
if (!is_array($vars) || empty($vars)) return $tpl;
|
|
|
|
$replace = [];
|
|
foreach ($vars as $k => $v) {
|
|
$key = trim((string)$k);
|
|
if ($key === '') continue;
|
|
$replace['{{' . $key . '}}'] = (string)$v;
|
|
$replace['{' . $key . '}'] = (string)$v;
|
|
}
|
|
return str_replace(array_keys($replace), array_values($replace), $tpl);
|
|
}
|
|
|
|
// ==================== Logging ====================
|
|
|
|
public function log($msg)
|
|
{
|
|
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
|
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
|
}
|
|
}
|