Files
tougao/application/api/controller/EmailClient.php
2026-04-29 17:59:13 +08:00

2971 lines
105 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\api\controller;
use think\Db;
use think\Env;
use think\Cache;
use think\Queue;
use PHPMailer\PHPMailer\PHPMailer;
use think\Validate;
use app\common\PromotionService;
class EmailClient extends Base
{
// ==================== SMTP Account Management ====================
/**
* Get SMTP accounts for a journal
* Params: journal_id
*/
public function getAccounts()
{
$journalId = intval($this->request->param('journal_id', 0));
if (!$journalId) {
return jsonError('journal_id is required');
}
$list = Db::name('journal_email')
->where('journal_id', $journalId)
->order('state asc, j_email_id asc')
->select();
foreach ($list as &$item) {
// $item['smtp_password'] = '******';
$item['remaining_today'] = max(0, $item['daily_limit'] - $item['today_sent']);
}
return jsonSuccess($list);
}
public function getAccountsAll(){
$list = Db::name('journal_email')
->where("state",0)
->order('state asc, j_email_id asc')
->select();
return jsonSuccess($list);
}
/**
* Add SMTP account
*/
public function addAccount()
{
$data = $this->request->post();
if (empty($data['journal_id']) || empty($data['smtp_host']) || empty($data['smtp_user']) || empty($data['smtp_password'])) {
return jsonError('journal_id, smtp_host, smtp_user, smtp_password are required');
}
$count = Db::name('journal_email')
->where('journal_id', intval($data['journal_id']))
->where('state', 0)
->count();
if ($count >= 4) {
return jsonError('Each journal allows at most 4 SMTP accounts');
}
$insert = [
'journal_id' => intval($data['journal_id']),
'smtp_host' => trim($data['smtp_host']),
'smtp_port' => intval($data['smtp_port'] ?? 465),
'smtp_user' => trim($data['smtp_user']),
'smtp_password' => trim($data['smtp_password']),
'smtp_encryption' => in_array($data['smtp_encryption'] ?? 'ssl', ['ssl', 'tls']) ? $data['smtp_encryption'] : 'ssl',
'smtp_from_name' => trim($data['smtp_from_name'] ?? ''),
'daily_limit' => intval($data['daily_limit'] ?? 100),
'today_sent' => 0,
'imap_host' => trim($data['imap_host'] ?? ''),
'imap_port' => intval($data['imap_port'] ?? 993),
'last_uid' => 0,
'state' => 0,
];
$id = Db::name('journal_email')->insertGetId($insert);
return jsonSuccess(['j_email_id' => $id]);
}
/**
* Update SMTP account
*/
public function updateAccount()
{
$data = $this->request->post();
$id = intval($data['j_email_id'] ?? 0);
if (!$id) {
return jsonError('j_email_id is required');
}
$account = Db::name('journal_email')->where('j_email_id', $id)->find();
if (!$account) {
return jsonError('Account not found');
}
$update = [];
$fields = ['smtp_host', 'smtp_port', 'smtp_user', 'smtp_password', 'smtp_encryption', 'smtp_from_name', 'daily_limit', 'imap_host', 'imap_port', 'state'];
foreach ($fields as $field) {
if (isset($data[$field])) {
$update[$field] = $data[$field];
}
}
if (isset($update['smtp_port'])) {
$update['smtp_port'] = intval($update['smtp_port']);
}
if (isset($update['imap_port'])) {
$update['imap_port'] = intval($update['imap_port']);
}
if (isset($update['daily_limit'])) {
$update['daily_limit'] = intval($update['daily_limit']);
}
if (isset($update['state'])) {
$update['state'] = intval($update['state']);
}
if (isset($update['smtp_encryption']) && !in_array($update['smtp_encryption'], ['ssl', 'tls'])) {
$update['smtp_encryption'] = 'ssl';
}
if (empty($update)) {
return jsonError('No fields to update');
}
Db::name('journal_email')->where('j_email_id', $id)->update($update);
return jsonSuccess(['j_email_id' => $id]);
}
/**
* Delete SMTP account
*/
public function deleteAccount()
{
$id = intval($this->request->param('j_email_id', 0));
if (!$id) {
return jsonError('j_email_id is required');
}
Db::name('journal_email')->where('j_email_id', $id)->delete();
return jsonSuccess(['deleted' => $id]);
}
/**
* Test SMTP connection
*/
public function testAccount()
{
$id = intval($this->request->param('j_email_id', 0));
$testEmail = trim($this->request->param('test_email', ''));
if (!$id) {
return jsonError('j_email_id is required');
}
if (empty($testEmail)) {
return jsonError('test_email is required');
}
$account = Db::name('journal_email')->where('j_email_id', $id)->find();
if (!$account) {
return jsonError('Account not found');
}
$result = $this->doSendEmail($account, $testEmail, 'SMTP Test', '<p>This is a test email from your journal system.</p>');
if ($result['status'] === 1) {
return jsonSuccess(['msg' => 'Test email sent successfully']);
} else {
return jsonError('Send failed: ' . $result['data']);
}
}
// ==================== Email Sending ====================
/**
* Send a single email using a specific journal's SMTP
* Params:
* - journal_id, to_email
* - subject/content OR template_id+vars_json
* - optional: style_id (mail_style) and j_email_id (specific SMTP account)
*/
public function sendOne()
{
$journalId = intval($this->request->param('journal_id', 0));
$toEmail = trim($this->request->param('to_email', ''));
$subject = trim($this->request->param('subject', ''));
$content = $this->request->param('content', '');
$templateId = intval($this->request->param('template_id', 0));
$varsJson = $this->request->param('vars_json', '');
$styleId = intval($this->request->param('style_id', 0));
$accountId = intval($this->request->param('j_email_id', 0));
if (!$journalId || empty($toEmail)) {
return jsonError('journal_id and to_email are required');
}
if ($templateId) {
$rendered = $this->renderFromTemplate($templateId, $journalId, $varsJson, $styleId);
if ($rendered['code'] !== 0) {
return jsonError($rendered['msg']);
}
$subject = $rendered['data']['subject'];
$content = $rendered['data']['body'];
} else {
if (empty($subject) || empty($content)) {
return jsonError('subject and content are required when template_id is not provided');
}
}
if ($accountId) {
$account = Db::name('journal_email')
->where('j_email_id', $accountId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
} else {
$account = $this->pickSmtpAccount($journalId);
}
if (!$account) {
return jsonError('No available SMTP account (all disabled or daily limit reached)');
}
$this->resetDailyCountIfNeeded($account);
if ($account['today_sent'] >= $account['daily_limit']) {
$account = $this->pickSmtpAccount($journalId);
if (!$account) {
return jsonError('All SMTP accounts have reached daily limit');
}
}
$result = $this->doSendEmail($account, $toEmail, $subject, $content);
if ($result['status'] === 1) {
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
return jsonSuccess([
'msg' => 'Email sent',
'from' => $account['smtp_user'],
'to' => $toEmail,
'j_email_id' => $account['j_email_id'],
]);
} else {
return jsonError('Send failed: ' . $result['data']);
}
}
/**
* 测试邮件:按指定 mail_template + mail_style 渲染后发送一封到指定邮箱。
* 渲染与正式推广任务一致(使用 PromotionService::renderFromTemplate变量含 expert_* / journal_*)。
*
* Params:
* journal_id (必填)
* template_id (必填)
* to_email (必填)
* style_id (选填,默认 0 表示不套 style 包裹)
* expert_id (选填,有则从库取专家填充变量;无则使用占位假数据)
* vars_json (选填JSON 对象,合并覆盖上述变量)
* j_email_id (选填,指定 SMTP否则自动选有余额的账号)
* skip_quota_count (选填,传 1 发送成功时不增加 today_sent避免测试占额度)
*/
public function sendTemplateStyleTest()
{
$journalId = intval($this->request->param('journal_id', 0));
$templateId = intval($this->request->param('template_id', 0));
$styleId = intval($this->request->param('style_id', 0));
$toEmail = trim($this->request->param('to_email', ''));
$expertId = intval($this->request->param('expert_id', 0));
$varsJson = $this->request->param('vars_json', '');
$accountId = intval($this->request->param('j_email_id', 0));
$skipQuota = intval($this->request->param('skip_quota_count', 0));
if (!$journalId || !$templateId || $toEmail === '') {
return jsonError('journal_id, template_id and to_email are required');
}
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
if (!$journal) {
return jsonError('Journal not found');
}
$service = new PromotionService();
$journalVars = $service->buildJournalVars($journal);
if ($expertId > 0) {
$expert = Db::name('expert')->where('expert_id', $expertId)->find();
if (!$expert) {
return jsonError('expert not found');
}else{
$expert_fields = Db::name('expert_fields')->where('expert_id', $expert['expert_id'])->select();
$field_str = '';
foreach ($expert_fields as $field){
if($field_str != ''){
$field_str .= ','.$field['field_name'];
}else{
$field_str = $field['field_name'];
}
}
$expert['fields'] = $field_str;
}
$expertVars = $service->buildExpertVars($expert);
} else {
$expertVars = [
'expert_title' => 'Dr.',
'expert_name' => 'Test Recipient',
'expert_email' => $toEmail,
'expert_affiliation' => 'Example University / Hospital',
'expert_field' => 'Clinical Research',
];
}
$vars = array_merge($journalVars, $expertVars);
if ($varsJson !== '' && $varsJson !== null) {
$extra = json_decode($varsJson, true);
if (is_array($extra)) {
$vars = array_merge($vars, $extra);
}
}
$rendered = $service->renderFromTemplate(
$templateId,
$journalId,
json_encode($vars, JSON_UNESCAPED_UNICODE),
$styleId
);
if ($rendered['code'] !== 0) {
return jsonError($rendered['msg']);
}
$subject = $rendered['data']['subject'];
$content = $rendered['data']['body'];
if ($accountId) {
$account = Db::name('journal_email')
->where('j_email_id', $accountId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
} else {
$account = $this->pickSmtpAccount($journalId);
}
if (!$account) {
return jsonError('No available SMTP account (all disabled or daily limit reached)');
}
$this->resetDailyCountIfNeeded($account);
if (!$skipQuota && $account['today_sent'] >= $account['daily_limit']) {
$account = $this->pickSmtpAccount($journalId);
if (!$account) {
return jsonError('All SMTP accounts have reached daily limit');
}
}
$result = $this->doSendEmail($account, $toEmail, $subject, $content);
if ($result['status'] === 1) {
if (!$skipQuota) {
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
}
return jsonSuccess([
'msg' => 'Test email sent',
'from' => $account['smtp_user'],
'to' => $toEmail,
'j_email_id' => $account['j_email_id'],
'subject_sent' => $subject,
]);
}
return jsonError('Send failed: ' . $result['data']);
}
/**
* Send batch emails to multiple experts
* Params: journal_id, expert_ids (comma separated),
* - either subject + content
* - or template_id (recommended), with optional style_id
*
* Supported variables in subject/content or template:
* {name}/{email}/{affiliation}/{field} and {{name}}/{{email}}/... (both styles)
*/
public function sendBatch()
{
$journalId = intval($this->request->param('journal_id', 0));
$expertIds = trim($this->request->param('expert_ids', ''));
$subject = trim($this->request->param('subject', ''));
$content = $this->request->param('content', '');
$templateId = intval($this->request->param('template_id', 0));
$styleId = intval($this->request->param('style_id', 0));
if (!$journalId || empty($expertIds)) {
return jsonError('journal_id and expert_ids are required');
}
if (!$templateId && (empty($subject) || empty($content))) {
return jsonError('subject and content are required when template_id is not provided');
}
$ids = array_map('intval', explode(',', $expertIds));
$experts = Db::name('expert')
->where('expert_id', 'in', $ids)
->where('state', 0)
->where('unsubscribed', 0)
->select();
if (empty($experts)) {
return jsonError('No valid experts found (state must be 0 and not unsubscribed)');
}
$sent = 0;
$failed = 0;
$skipped = 0;
$errors = [];
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
$journalVars = $this->buildJournalVars($journal);
foreach ($experts as $expert) {
$account = $this->pickSmtpAccount($journalId);
if (!$account) {
$skipped += (count($experts) - $sent - $failed);
$errors[] = 'All SMTP accounts reached daily limit, stopped at #' . ($sent + $failed + 1);
break;
}
$expert['application_link_yeditorial_board'] = \app\common\PromotionService::buildYboardApplyUrl(
intval($expert['expert_id'] ?? 0),
intval($journalId)
);
$expertVars = $this->buildExpertVars($expert);
$vars = array_merge($journalVars, $expertVars);
if ($templateId) {
$rendered = $this->renderFromTemplate($templateId, $journalId, json_encode($vars, JSON_UNESCAPED_UNICODE), $styleId);
if ($rendered['code'] !== 0) {
$failed++;
$errors[] = $expert['email'] . ': ' . $rendered['msg'];
continue;
}
$personalSubject = $rendered['data']['subject'];
$personalContent = $rendered['data']['body'];
} else {
$personalContent = $this->renderVars($content, $vars);
$personalSubject = $this->renderVars($subject, $vars);
}
$result = $this->doSendEmail($account, $expert['email'], $personalSubject, $personalContent);
if ($result['status'] === 1) {
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(['state' => 1, 'ltime' => time()]);
$sent++;
} else {
$failed++;
$errors[] = $expert['email'] . ': ' . $result['data'];
}
$delay = rand(30, 60);
sleep($delay);
}
return jsonSuccess([
'sent' => $sent,
'failed' => $failed,
'skipped' => $skipped,
'errors' => $errors,
]);
}
/**
* Get sending stats for a journal
*/
public function getStats()
{
$journalId = intval($this->request->param('journal_id', 0));
if (!$journalId) {
return jsonError('journal_id is required');
}
$accounts = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->select();
$totalLimit = 0;
$totalSent = 0;
$details = [];
foreach ($accounts as $acc) {
$this->resetDailyCountIfNeeded($acc);
$totalLimit += $acc['daily_limit'];
$totalSent += $acc['today_sent'];
$details[] = [
'j_email_id' => $acc['j_email_id'],
'smtp_user' => $acc['smtp_user'],
'daily_limit' => $acc['daily_limit'],
'today_sent' => $acc['today_sent'],
'remaining' => max(0, $acc['daily_limit'] - $acc['today_sent']),
];
}
return jsonSuccess([
'total_daily_limit' => $totalLimit,
'total_today_sent' => $totalSent,
'total_remaining' => max(0, $totalLimit - $totalSent),
'accounts' => $details,
]);
}
/**
* Reset daily sent count (called by cron or manually)
*/
public function resetDailyCount()
{
$journalId = intval($this->request->param('journal_id', 0));
if ($journalId) {
$count = Db::name('journal_email')
->where('journal_id', $journalId)
->update(['today_sent' => 0]);
} else {
$count = Db::name('journal_email')->update(['today_sent' => 0]);
}
return jsonSuccess(['reset_count' => $count]);
}
// ==================== Inbox - Sync ====================
/**
* Sync emails from IMAP to local database
* Params: j_email_id (single account) or journal_id (all accounts)
*/
public function syncInbox()
{
$accountId = intval($this->request->param('j_email_id', 0));
$journalId = intval($this->request->param('journal_id', 0));
if (!$accountId && !$journalId) {
return jsonError('j_email_id or journal_id is required');
}
set_time_limit(300);
if ($accountId) {
$accounts = Db::name('journal_email')->where('j_email_id', $accountId)->where('state', 0)->select();
} else {
$accounts = Db::name('journal_email')->where('journal_id', $journalId)->where('state', 0)->select();
}
$report = [];
$accountUpdates = [];
foreach ($accounts as $account) {
$one = $this->doSyncAccount($account);
$report[] = $one;
$aid = intval($account['j_email_id']);
if (!isset($accountUpdates[$aid])) {
$accountUpdates[$aid] = [
'synced' => 0,
'bounced' => 0,
'journal_id' => intval($account['journal_id']),
];
}
$accountUpdates[$aid]['synced'] += intval(isset($one['synced']) ? $one['synced'] : 0);
$accountUpdates[$aid]['bounced'] += intval(isset($one['bounced']) ? $one['bounced'] : 0);
}
// 发布 SSE 事件:按 j_email_id 维度通知;前端收到后自行刷新列表
foreach ($accountUpdates as $aid => $sum) {
$this->publishInboxUpdatedEvent($aid, [
'type' => 'inbox_updated',
'j_email_id' => intval($aid),
'journal_id' => intval($sum['journal_id']),
'synced' => intval($sum['synced']),
'bounced' => intval($sum['bounced']),
'time' => date('Y-m-d H:i:s'),
]);
}
return jsonSuccess(['report' => $report]);
}
private function doSyncAccount($account)
{
if (empty($account['imap_host'])) {
return ['j_email_id' => $account['j_email_id'], 'error' => 'IMAP not configured'];
}
$imap = $this->connectImap($account);
if (!$imap) {
return ['j_email_id' => $account['j_email_id'], 'error' => 'IMAP connection failed'];
}
$lastUid = intval($account['last_uid']);
$synced = 0;
$bounced = 0;
$maxUid = $lastUid;
try {
// 仅拉取最近 7 天邮件(避免首次全量导致超时)
$since = date('d-M-Y', strtotime('-7 days'));
$uids = imap_search($imap, 'SINCE "' . $since . '"', SE_UID);
if (!$uids) {
$uids = [];
}
sort($uids);
foreach ($uids as $uid) {
$uid = intval($uid);
if ($uid <= $lastUid) continue;
// 每封邮件单独取 overviewFT_UID 模式下传 uid
$ovArr = imap_fetch_overview($imap, (string)$uid, FT_UID);
$overview = (!empty($ovArr) && isset($ovArr[0])) ? $ovArr[0] : null;
if (!$overview) {
continue;
}
$messageId = isset($overview->message_id) ? trim($overview->message_id) : '';
if ($messageId && Db::name('email_inbox')->where('message_id', $messageId)->find()) {
if ($uid > $maxUid) $maxUid = $uid;
continue;
}
$header = imap_fetchheader($imap, $uid, FT_UID);
$struct = imap_fetchstructure($imap, $uid, FT_UID);
$body = $this->getEmailBody($imap, $uid, $struct);
$fromParts = isset($overview->from) ? $this->parseAddress($overview->from) : ['email' => '', 'name' => ''];
$subject = isset($overview->subject) ? $this->decodeMimeStr($overview->subject) : '';
$toEmail = isset($overview->to) ? $this->parseAddress($overview->to)['email'] : $account['smtp_user'];
$emailDate = isset($overview->date) ? strtotime($overview->date) : time();
if (!$emailDate) $emailDate = time();
$hasAttachment = $this->checkAttachment($struct);
$isBounce = $this->detectBounce($fromParts['email'], $subject, $header);
$bounceEmail = '';
if ($isBounce) {
$bounceEmail = $this->extractBounceRecipient($body['text'], $header);
if ($bounceEmail) {
Db::name('expert')->where('email', strtolower($bounceEmail))->update(['state' => 4]);
$bounced++;
}
}
$insert = [
'j_email_id' => $account['j_email_id'],
'journal_id' => $account['journal_id'],
'message_id' => mb_substr($messageId, 0, 255),
'uid' => $uid,
'from_email' => mb_substr($fromParts['email'], 0, 255),
'from_name' => mb_substr($fromParts['name'], 0, 255),
'to_email' => mb_substr($toEmail, 0, 255),
'subject' => mb_substr($subject, 0, 512),
'content_html' => $body['html'],
'content_text' => $body['text'],
'email_date' => $emailDate,
'has_attachment' => $hasAttachment ? 1 : 0,
'is_read' => mb_substr($subject, 0, 512)=="failure notice"?1:0,
'is_starred' => 0,
'is_bounce' => $isBounce ? 1 : 0,
'bounce_email' => mb_substr($bounceEmail, 0, 128),
'folder' => 'INBOX',
'ctime' => time(),
'state' => 0,
];
try {
Db::name('email_inbox')->insert($insert);
$synced++;
} catch (\Exception $e) {
// skip duplicate
}
if ($uid > $maxUid) {
$maxUid = $uid;
}
}
if ($maxUid > $lastUid) {
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->update(['last_uid' => $maxUid]);
}
} catch (\Exception $e) {
imap_close($imap);
return ['j_email_id' => $account['j_email_id'], 'error' => $e->getMessage()];
}
imap_close($imap);
return [
'j_email_id' => $account['j_email_id'],
'smtp_user' => $account['smtp_user'],
'synced' => $synced,
'bounced' => $bounced,
'last_uid' => $maxUid,
];
}
// ==================== Inbox - Read ====================
/**
* Get inbox list with pagination and filtering
*/
public function getInboxList()
{
$journalId = intval($this->request->param('journal_id', 0));
$accountId = intval($this->request->param('j_email_id', 0));
$isRead = $this->request->param('is_read', '-1');
$isBounce = $this->request->param('is_bounce', '-1');
$keyword = trim($this->request->param('keyword', ''));
$folder = trim($this->request->param('folder', 'INBOX'));
$page = max(1, intval($this->request->param('page', 1)));
$perPage = max(1, min(intval($this->request->param('per_page', 20)), 100));
$where = ['state' => 0, 'folder' => $folder];
if ($accountId) {
$where['j_email_id'] = $accountId;
} elseif ($journalId) {
$where['journal_id'] = $journalId;
} else {
return jsonError('j_email_id or journal_id is required');
}
if ($isRead !== '-1' && $isRead !== '') {
$where['is_read'] = intval($isRead);
}
if ($isBounce !== '-1' && $isBounce !== '') {
$where['is_bounce'] = intval($isBounce);
}
$query = Db::name('email_inbox')->where($where);
if ($keyword !== '') {
$query->where('from_email|from_name|subject', 'like', '%' . $keyword . '%');
}
$total = Db::name('email_inbox')->where($where)->count();
$list = $query
->field('inbox_id,j_email_id,from_email,from_name,to_email,subject,email_date,has_attachment,is_read,is_starred,is_bounce,bounce_email,ctime')
->order('email_date desc')
->page($page, $perPage)
->select();
return jsonSuccess([
'list' => $list,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $total > 0 ? ceil($total / $perPage) : 0,
]);
}
public function getOneEmail(){
$data = $this->request->post();
$rule = new Validate([
"j_email_id"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$email_info = DB::name("journal_email")->where("j_email_id",$data['j_email_id'])->find();
$re['email'] = $email_info;
return jsonSuccess($re);
}
/**
* Get single email detail
*/
public function getEmailDetail()
{
$inboxId = intval($this->request->param('inbox_id', 0));
if (!$inboxId) {
return jsonError('inbox_id is required');
}
$email = Db::name('email_inbox')->where('inbox_id', $inboxId)->where('state', 0)->find();
if (!$email) {
return jsonError('Email not found');
}
if (!$email['is_read']) {
Db::name('email_inbox')->where('inbox_id', $inboxId)->update(['is_read' => 1]);
$email['is_read'] = 1;
}
return jsonSuccess($email);
}
/**
* Get unread count
*/
public function getUnreadCount()
{
$journalId = intval($this->request->param('journal_id', 0));
$accountId = intval($this->request->param('j_email_id', 0));
$where = ['state' => 0, 'is_read' => 0, 'folder' => 'INBOX'];
if ($accountId) {
$where['j_email_id'] = $accountId;
} elseif ($journalId) {
$where['journal_id'] = $journalId;
} else {
return jsonError('j_email_id or journal_id is required');
}
$total = Db::name('email_inbox')->where($where)->count();
$bounce = Db::name('email_inbox')->where($where)->where('is_bounce', 1)->count();
return jsonSuccess([
'unread' => $total,
'unread_bounce' => $bounce,
]);
}
/**
* SSE: 订阅收件箱更新事件(按 j_email_id 维度)。
* 前端建议按 j_email_id 建立连接,收到 inbox_updated 后自行请求 getInboxList/getUnreadCount。
*
* URL: /api/email_client/inboxSse?j_email_id=3
*/
public function inboxSse()
{
$accountId = intval($this->request->param('j_email_id', 0));
if (!$accountId) {
return jsonError('j_email_id is required');
}
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) {
ob_end_flush();
}
@ob_implicit_flush(1);
@set_time_limit(0);
$eventFile = $this->getInboxSseEventFile($accountId);
$lastMtime = is_file($eventFile) ? filemtime($eventFile) : 0;//记录初始时间
echo "retry: 3000\n";
echo "event: connected\n";
echo "data: " . json_encode([
'msg' => 'SSE connected',
'j_email_id' => $accountId,
'time' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
// 单连接保持 1.5 分钟,随后由前端自动重连
$maxLoop = 90;
for ($i = 0; $i < $maxLoop; $i++) {
if (connection_aborted()) {
break;
}
clearstatcache(true, $eventFile);
$mtime = is_file($eventFile) ? filemtime($eventFile) : 0;//新时间
if ($mtime > $lastMtime) {
$lastMtime = $mtime;
$content = @file_get_contents($eventFile);
$payload = json_decode($content, true);
if (!is_array($payload)) {
$payload = [
'type' => 'inbox_updated',
'j_email_id' => $accountId,
'time' => date('Y-m-d H:i:s'),
];
}
echo "event: inbox_updated\n";
echo "id: " . $mtime . "\n";
echo "data: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
} else {
// 心跳,避免中间层断开空闲 SSE 连接
echo ": ping " . time() . "\n\n";
flush();
}
sleep(1);
}
echo "event: disconnected\n";
exit;
}
/**
* SSE demo: fixed message stream for Vue EventSource testing.
* URL: /api/email_client/sseDemo
*/
public function sseDemo()
{
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
while (ob_get_level() > 0) {
ob_end_flush();
}
@ob_implicit_flush(1);
echo "retry: 3000\n";
echo "event: connected\n";
echo "data: " . json_encode([
'msg' => 'SSE connected',
'time' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
for ($i = 1; $i <= 5; $i++) {
if (connection_aborted()) {
break;
}
echo "event: message\n";
echo "id: " . $i . "\n";
echo "data: " . json_encode([
'msg' => 'This is fixed SSE demo content',
'index' => $i,
'time' => date('Y-m-d H:i:s'),
], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
sleep(2);
}
echo "event: done\n";
echo "data: " . json_encode(['msg' => 'SSE demo finished'], JSON_UNESCAPED_UNICODE) . "\n\n";
flush();
exit;
}
// ==================== Inbox - Actions ====================
/**
* Mark emails as read/unread
* Params: inbox_ids (comma separated), is_read (0 or 1)
*/
public function markRead()
{
$ids = trim($this->request->param('inbox_ids', ''));
$isRead = intval($this->request->param('is_read', 1));
if (empty($ids)) {
return jsonError('inbox_ids is required');
}
$idArr = array_map('intval', explode(',', $ids));
$count = Db::name('email_inbox')->where('inbox_id', 'in', $idArr)->update(['is_read' => $isRead ? 1 : 0]);
return jsonSuccess(['updated' => $count]);
}
/**
* Star/unstar emails
*/
public function markStar()
{
$ids = trim($this->request->param('inbox_ids', ''));
$isStarred = intval($this->request->param('is_starred', 1));
if (empty($ids)) {
return jsonError('inbox_ids is required');
}
$idArr = array_map('intval', explode(',', $ids));
$count = Db::name('email_inbox')->where('inbox_id', 'in', $idArr)->update(['is_starred' => $isStarred ? 1 : 0]);
return jsonSuccess(['updated' => $count]);
}
/**
* Delete emails (soft delete)
*/
public function deleteEmail()
{
$ids = trim($this->request->param('inbox_ids', ''));
if (empty($ids)) {
return jsonError('inbox_ids is required');
}
$idArr = array_map('intval', explode(',', $ids));
$count = Db::name('email_inbox')->where('inbox_id', 'in', $idArr)->update(['state' => 1]);
return jsonSuccess(['deleted' => $count]);
}
/**
* Download attachment from IMAP in real time
* Params: inbox_id, part_index (attachment index, starting from 0)
*/
public function getAttachment()
{
$inboxId = intval($this->request->param('inbox_id', 0));
$partIndex = intval($this->request->param('part_index', 0));
if (!$inboxId) {
return jsonError('inbox_id is required');
}
$email = Db::name('email_inbox')->where('inbox_id', $inboxId)->where('state', 0)->find();
if (!$email) {
return jsonError('Email not found');
}
$account = Db::name('journal_email')->where('j_email_id', $email['j_email_id'])->find();
if (!$account || empty($account['imap_host'])) {
return jsonError('IMAP not configured for this account');
}
$imap = $this->connectImap($account);
if (!$imap) {
return jsonError('IMAP connection failed');
}
$struct = imap_fetchstructure($imap, $email['uid'], FT_UID);
$attachments = $this->listAttachments($struct);
if (!isset($attachments[$partIndex])) {
imap_close($imap);
return jsonError('Attachment not found at index ' . $partIndex);
}
$att = $attachments[$partIndex];
$partNum = $att['part_number'];
$filename = $att['filename'];
$encoding = $att['encoding'];
$body = imap_fetchbody($imap, $email['uid'], $partNum, FT_UID);
imap_close($imap);
if ($encoding == 3) {
$body = base64_decode($body);
} elseif ($encoding == 4) {
$body = quoted_printable_decode($body);
}
$dir = ROOT_PATH . 'public' . DS . 'attachments';
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
$safeName = time() . '_' . preg_replace('/[^a-zA-Z0-9._\-]/', '_', $filename);
$filepath = $dir . DS . $safeName;
file_put_contents($filepath, $body);
return jsonSuccess([
'filename' => $filename,
'file_url' => '/attachments/' . $safeName,
'file_size' => strlen($body),
]);
}
/**
* List attachments for an email (without downloading)
*/
public function listEmailAttachments()
{
$inboxId = intval($this->request->param('inbox_id', 0));
if (!$inboxId) {
return jsonError('inbox_id is required');
}
$email = Db::name('email_inbox')->where('inbox_id', $inboxId)->where('state', 0)->find();
if (!$email) {
return jsonError('Email not found');
}
$account = Db::name('journal_email')->where('j_email_id', $email['j_email_id'])->find();
if (!$account || empty($account['imap_host'])) {
return jsonError('IMAP not configured');
}
$imap = $this->connectImap($account);
if (!$imap) {
return jsonError('IMAP connection failed');
}
$struct = imap_fetchstructure($imap, $email['uid'], FT_UID);
$attachments = $this->listAttachments($struct);
imap_close($imap);
$result = [];
foreach ($attachments as $i => $att) {
$result[] = [
'index' => $i,
'filename' => $att['filename'],
'size' => $att['size'] ?? 0,
];
}
return jsonSuccess($result);
}
// ==================== IMAP Helpers ====================
private function connectImap($account)
{
$host = $account['imap_host'];
$port = intval($account['imap_port'] ?: 993);
$user = $account['smtp_user'];
$password = $account['smtp_password'];
$encryption = ($port == 993) ? 'ssl' : '';
$flags = '/imap';
if ($encryption === 'ssl') {
$flags .= '/ssl';
}
$flags .= '/novalidate-cert';
$mailbox = '{' . $host . ':' . $port . $flags . '}INBOX';
$imap = @imap_open($mailbox, $user, $password, 0, 1);
if (!$imap) {
return false;
}
return $imap;
}
private function getEmailBody($imap, $uid, $struct)
{
$html = '';
$text = '';
if (!isset($struct->parts) || empty($struct->parts)) {
$body = imap_fetchbody($imap, $uid, '1', FT_UID);
$body = $this->decodeBody($body, $struct->encoding ?? 0);
$charset = $this->getCharset($struct);
$body = $this->convertToUtf8($body, $charset);
if (isset($struct->subtype) && strtoupper($struct->subtype) === 'HTML') {
$html = $body;
} else {
$text = $body;
}
} else {
foreach ($struct->parts as $index => $part) {
$partNum = ($index + 1);
if (isset($part->subtype)) {
$subtype = strtoupper($part->subtype);
if ($subtype === 'PLAIN' && empty($text)) {
$body = imap_fetchbody($imap, $uid, (string) $partNum, FT_UID);
$text = $this->decodeBody($body, $part->encoding ?? 0);
$text = $this->convertToUtf8($text, $this->getCharset($part));
} elseif ($subtype === 'HTML' && empty($html)) {
$body = imap_fetchbody($imap, $uid, (string) $partNum, FT_UID);
$html = $this->decodeBody($body, $part->encoding ?? 0);
$html = $this->convertToUtf8($html, $this->getCharset($part));
} elseif ($subtype === 'ALTERNATIVE' || $subtype === 'MIXED' || $subtype === 'RELATED') {
if (isset($part->parts)) {
foreach ($part->parts as $subIndex => $subPart) {
$subPartNum = $partNum . '.' . ($subIndex + 1);
$sub = strtoupper($subPart->subtype ?? '');
if ($sub === 'PLAIN' && empty($text)) {
$body = imap_fetchbody($imap, $uid, $subPartNum, FT_UID);
$text = $this->decodeBody($body, $subPart->encoding ?? 0);
$text = $this->convertToUtf8($text, $this->getCharset($subPart));
} elseif ($sub === 'HTML' && empty($html)) {
$body = imap_fetchbody($imap, $uid, $subPartNum, FT_UID);
$html = $this->decodeBody($body, $subPart->encoding ?? 0);
$html = $this->convertToUtf8($html, $this->getCharset($subPart));
}
}
}
}
}
}
}
if (empty($html) && !empty($text)) {
$html = '<pre>' . htmlspecialchars($text) . '</pre>';
}
return ['html' => $html, 'text' => $text];
}
private function decodeBody($body, $encoding)
{
switch ($encoding) {
case 3: return base64_decode($body);
case 4: return quoted_printable_decode($body);
default: return $body;
}
}
private function getCharset($struct)
{
if (isset($struct->parameters)) {
foreach ($struct->parameters as $param) {
if (strtolower($param->attribute) === 'charset') {
return $param->value;
}
}
}
return 'UTF-8';
}
private function convertToUtf8($str, $charset)
{
$charset = strtoupper(trim($charset));
if ($charset === 'UTF-8' || $charset === 'UTF8' || empty($charset)) {
return $str;
}
$converted = @iconv($charset, 'UTF-8//IGNORE', $str);
return $converted !== false ? $converted : $str;
}
private function decodeMimeStr($str)
{
$elements = imap_mime_header_decode($str);
$decoded = '';
foreach ($elements as $el) {
$charset = ($el->charset === 'default') ? 'ASCII' : $el->charset;
$decoded .= $this->convertToUtf8($el->text, $charset);
}
return $decoded;
}
private function parseAddress($addressStr)
{
$decoded = $this->decodeMimeStr($addressStr);
$email = '';
$name = '';
if (preg_match('/<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/', $decoded, $m)) {
$email = strtolower($m[1]);
}
if (preg_match('/^"?([^"<]+)"?\s*</', $decoded, $m)) {
$name = trim($m[1]);
}
return ['email' => $email, 'name' => $name];
}
private function checkAttachment($struct)
{
if (!isset($struct->parts)) {
return false;
}
foreach ($struct->parts as $part) {
$disposition = '';
if (isset($part->disposition)) {
$disposition = strtolower($part->disposition);
}
if ($disposition === 'attachment') {
return true;
}
if (isset($part->ifdparameters) && $part->ifdparameters) {
foreach ($part->dparameters as $dp) {
if (strtolower($dp->attribute) === 'filename') {
return true;
}
}
}
}
return false;
}
private function listAttachments($struct, $prefix = '')
{
$attachments = [];
if (!isset($struct->parts)) {
return $attachments;
}
foreach ($struct->parts as $index => $part) {
$partNum = $prefix ? ($prefix . '.' . ($index + 1)) : (string) ($index + 1);
$filename = '';
if (isset($part->dparameters)) {
foreach ($part->dparameters as $dp) {
if (strtolower($dp->attribute) === 'filename') {
$filename = $this->decodeMimeStr($dp->value);
}
}
}
if (empty($filename) && isset($part->parameters)) {
foreach ($part->parameters as $p) {
if (strtolower($p->attribute) === 'name') {
$filename = $this->decodeMimeStr($p->value);
}
}
}
if (!empty($filename)) {
$attachments[] = [
'filename' => $filename,
'part_number' => $partNum,
'encoding' => $part->encoding ?? 0,
'size' => $part->bytes ?? 0,
];
}
if (isset($part->parts)) {
$sub = $this->listAttachments($part, $partNum);
$attachments = array_merge($attachments, $sub);
}
}
return $attachments;
}
// ==================== Bounce Detection ====================
private function detectBounce($fromEmail, $subject, $header)
{
$fromEmail = strtolower($fromEmail);
if (strpos($fromEmail, 'mailer-daemon') !== false || strpos($fromEmail, 'postmaster') !== false) {
return true;
}
$subjectLower = strtolower($subject);
$bounceKeywords = ['undelivered', 'delivery status', 'delivery failed', 'mail delivery failed', 'returned mail', 'undeliverable', 'failure notice'];
foreach ($bounceKeywords as $kw) {
if (strpos($subjectLower, $kw) !== false) {
return true;
}
}
if (stripos($header, 'report-type=delivery-status') !== false) {
return true;
}
return false;
}
private function extractBounceRecipient($bodyText, $header)
{
if (preg_match('/Final-Recipient:.*?;\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/i', $header . "\n" . $bodyText, $m)) {
return strtolower($m[1]);
}
if (preg_match('/Original-Recipient:.*?;\s*<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?/i', $header . "\n" . $bodyText, $m)) {
return strtolower($m[1]);
}
if (preg_match('/<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?\s+was not delivered/i', $bodyText, $m)) {
return strtolower($m[1]);
}
if (preg_match('/delivery to.*?<?([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})>?.*?failed/i', $bodyText, $m)) {
return strtolower($m[1]);
}
return '';
}
// ==================== Promotion Tasks ====================
/**
* 基于任务工厂创建单个推广任务(手动触发)
*
* 参数:
* - promotion_factory_id 必填工厂ID任务的模板/领域/国家/发送参数均从工厂读取
* - send_date 可选默认明天Y-m-d
* - task_name (可选,默认 "Manual-{journal_title}-F{factory_id}-{send_date}")
* - no_repeat_days (可选,默认 30覆盖默认不重复天数
* - min_interval/max_interval/max_bounce_rate/send_start_hour/send_end_hour 可选覆盖
*/
public function createTask()
{
$factoryId = intval($this->request->param('promotion_factory_id', 0));
$sendDate = trim($this->request->param('send_date', date('Y-m-d', strtotime('+1 day'))));
$taskName = trim($this->request->param('task_name', ''));
$noRepeatDays = intval($this->request->param('no_repeat_days', 30));
$minInterval = intval($this->request->param('min_interval', 30));
$maxInterval = intval($this->request->param('max_interval', 60));
$maxBounceRate = intval($this->request->param('max_bounce_rate', 5));
$sendStartHour = intval($this->request->param('send_start_hour', 8));
$sendEndHour = intval($this->request->param('send_end_hour', 22));
if (!$factoryId) {
return jsonError('promotion_factory_id is required');
}
$factory = Db::name('promotion_factory')
->where('promotion_factory_id', $factoryId)
->find();
if (!$factory) {
return jsonError('Factory not found');
}
if (intval($factory['state']) !== 0) {
return jsonError('Factory is disabled');
}
$expertType = intval($factory['expert_type']);
if (!in_array($expertType, [2, 3, 5], true)) {
return jsonError('Only expert_type=2(Editorial Board), 3(Young Editorial Board) or 5(Expert pool) is supported currently');
}
$journalId = intval($factory['journal_id']);
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
if (!$journal) {
return jsonError('Journal not found');
}
$sendDateVal = null;
if ($sendDate !== '') {
$ts = strtotime($sendDate);
if ($ts !== false) {
$sendDateVal = date('Y-m-d', $ts);
}
}
if ($sendDateVal) {
$existTask = Db::name('promotion_task')
->where('factory_id', $factoryId)
->where('send_date', $sendDateVal)
->where('state', 'in', [0, 1, 5])
->find();
if ($existTask) {
return jsonError('Factory already has an active task for send_date=' . $sendDateVal . ' (task_id=' . $existTask['task_id'] . ')');
}
}
$templateId = intval($factory['template_id']) > 0
? intval($factory['template_id'])
: intval($journal['default_template_id']);
$styleId = intval($factory['style_id']) > 0
? intval($factory['style_id'])
: intval(isset($journal['default_style_id']) ? $journal['default_style_id'] : 0);
if ($templateId <= 0) {
return jsonError('template_id is not set on factory or journal');
}
$tpl = Db::name('mail_template')
->where('template_id', $templateId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
if (!$tpl) {
return jsonError('Template not found for this journal');
}
$scene = $tpl['scene'] ?? 'promotion';
$smtpIds = trim((string)$factory['email_ids']);
if ($smtpIds === '') {
$smtpCount = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->count();
if ($smtpCount == 0) {
return jsonError('No active SMTP account for this journal');
}
}
// 内部受众type∈{1..4})忽略领域 / 国家筛选;外部 expert 库type=5正常解析
if ($expertType === 5) {
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
if (empty($fields)) {
return jsonError('No valid fields resolved from factory.fetch_ids');
}
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
} else {
$fields = [];
$targetPartitions = '';
$targetCountryIds = '';
}
$dailyLimit = max(1, intval($factory['send_count']));
$experts = $this->findEligibleExperts(
$fields, $noRepeatDays, $dailyLimit,
$targetPartitions, $targetCountryIds,
$expertType, $journalId
);
if (empty($experts)) {
return jsonError('No eligible audience found (all may have been contacted recently)');
}
if ($taskName === '') {
$taskName = 'Manual-' . ($journal['title'] ?? $journalId) . '-F' . $factoryId . '-' . ($sendDateVal ?: date('Y-m-d H:i'));
}
$now = time();
$taskId = Db::name('promotion_task')->insertGetId([
'journal_id' => $journalId,
'factory_id' => $factoryId,
'template_id' => $templateId,
'style_id' => $styleId,
'scene' => $scene,
'type' => intval($factory['type']),
'expert_type' => intval($factory['expert_type']),
'task_name' => $taskName,
'total_count' => count($experts),
'sent_count' => 0,
'fail_count' => 0,
'bounce_count' => 0,
'state' => 0,
'smtp_ids' => $smtpIds,
'min_interval' => max(5, $minInterval),
'max_interval' => max($minInterval, $maxInterval),
'max_bounce_rate' => $maxBounceRate,
'no_repeat_days' => $noRepeatDays,
'send_start_hour' => $sendStartHour,
'send_end_hour' => $sendEndHour,
'send_date' => $sendDateVal,
'target_partitions' => $targetPartitions,
'target_country_ids' => $targetCountryIds,
'ctime' => $now,
'utime' => $now,
]);
$logs = [];
foreach ($experts as $expert) {
$logs[] = [
'task_id' => $taskId,
'expert_id' => $expertType === 5 ? intval($expert['expert_id']) : 0,
'user_id' => $expertType === 5 ? 0 : intval($expert['user_id']),
'j_email_id' => 0,
'email_to' => $expert['email'],
'subject' => '',
'state' => 0,
'error_msg' => '',
'send_time' => 0,
'ctime' => $now,
];
}
Db::name('promotion_email_log')->insertAll($logs);
$msg = 'Task created, call startTask to begin sending';
if ($sendDateVal) {
$msg = 'Task created for send_date=' . $sendDateVal . ', will be prepared by cron and triggered on that day';
}
return jsonSuccess([
'task_id' => $taskId,
'factory_id' => $factoryId,
'total_count' => count($experts),
'state' => 0,
'send_date' => $sendDateVal,
'msg' => $msg,
]);
}
/**设置期刊默认
* @return \think\response\Json
* @throws \think\Exception
* @throws \think\db\exception\DataNotFoundException
* @throws \think\db\exception\ModelNotFoundException
* @throws \think\exception\DbException
* @throws \think\exception\PDOException
*/
public function setDefaultPromotion(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require",
"default_template_id"=>"require",
"default_style_id"=>"require",
"start_promotion"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$update['default_template_id']=$data['default_template_id'];
$update['default_style_id'] = $data['default_style_id'];
$update['start_promotion'] = $data['start_promotion'];
$this->journal_obj->where("journal_id",$data['journal_id'])->update($update);
$info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
return jsonSuccess(['journal'=>$info]);
}
public function getPromotionJournalList(){
$data = $this->request->post();
$rule = new Validate([
"user_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$journals = $this->journal_obj
->field("t_journal.journal_id,t_journal.title,t_journal.issn,t_journal.default_template_id,t_journal.default_style_id,t_journal.start_promotion")
->where("editor_id",$data['user_id'])->where("state",0)->select();
//丰富期刊内容
return jsonSuccess(["list"=>$journals]);
}
public function getPromotionJournalDetail(){
$data = $this->request->post();
$rule = new Validate([
"journal_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$info = $this->journal_obj->where("journal_id",$data['journal_id'])->find();
$template_info = [];
if($info['default_style_id']!==0){
$template_info = Db::name("mail_template")->where("template_id",$info['default_template_id'])->find();
}
$style_info = [];
if($info['default_style_id']!==0){
$style_info = Db::name("mail_style")->where("style_id",$info['default_style_id'])->find();
}
$re['journal'] = $info;
$re['template'] = $template_info;
$re['style'] = $style_info;
return jsonSuccess($re);
}
// ==================== Journal Promotion Field Mapping ====================
/**
* 设置/更新期刊的推广领域(全量覆盖)
* 参数: journal_id, fetch_ids (逗号分隔的 expert_fetch_id 列表,如 "1,3,5")
*/
public function setJournalPromotionFields()
{
$data = $this->request->post();
$journalId = intval(isset($data['journal_id']) ? $data['journal_id'] : 0);
$fetchIds = trim(isset($data['fetch_ids']) ? $data['fetch_ids'] : '');
if (!$journalId) {
return jsonError('journal_id is required');
}
$newIds = [];
if ($fetchIds !== '') {
$newIds = array_unique(array_map('intval', explode(',', $fetchIds)));
$newIds = array_filter($newIds, function ($v) { return $v > 0; });
}
$currentIds = Db::name('journal_promotion_field')
->where('journal_id', $journalId)
->where('state', 0)
->column('expert_fetch_id');
$toAdd = array_diff($newIds, $currentIds);
$toRemove = array_diff($currentIds, $newIds);
if (!empty($toRemove)) {
Db::name('journal_promotion_field')
->where('journal_id', $journalId)
->where('expert_fetch_id', 'in', $toRemove)
->where('state', 0)
->update(['state' => 1]);
}
$now = time();
foreach ($toAdd as $fetchId) {
$exists = Db::name('journal_promotion_field')
->where('journal_id', $journalId)
->where('expert_fetch_id', $fetchId)
->find();
if ($exists) {
if ($exists['state'] == 1) {
Db::name('journal_promotion_field')
->where('jpf_id', $exists['jpf_id'])
->update(['state' => 0]);
}
} else {
Db::name('journal_promotion_field')->insert([
'journal_id' => $journalId,
'expert_fetch_id' => $fetchId,
'state' => 0,
'ctime' => $now,
]);
}
}
$finalList = Db::name('journal_promotion_field')
->alias('jpf')
->join('t_expert_fetch ef', 'ef.expert_fetch_id = jpf.expert_fetch_id', 'left')
->where('jpf.journal_id', $journalId)
->where('jpf.state', 0)
->field('jpf.jpf_id, jpf.expert_fetch_id, ef.field')
->select();
return jsonSuccess([
'added' => count($toAdd),
'removed' => count($toRemove),
'current' => $finalList,
]);
}
/**
* 获取所有可选的抓取领域(供期刊选择时的下拉列表)
* 返回 t_expert_fetch 中 state=0 的所有记录
*/
public function getAvailableFields()
{
$list = Db::name('expert_fetch')
->where('state', 0)
->order('field asc')
->select();
foreach ($list as &$item) {
$item['last_time_text'] = $item['last_time'] ? date('Y-m-d H:i:s', $item['last_time']) : '';
}
return jsonSuccess($list);
}
/**
* 为单个任务预生成邮件:异步入队
* Params: task_id
*
* 返回:
* queued 是否已入队false 表示当前没有待准备邮件)
* task_id 任务ID
* pending 需要准备的邮件数(大约值;实际以队列执行为准)
*/
public function prepareTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$service = new PromotionService();
$result = $service->prepareTask($taskId);
if ($result['error']) {
return jsonError($result['error']);
}
return jsonSuccess($result);
}
public function mytestg(){
$data = $this->request->post();
$rule = new Validate([
"id"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$service = new PromotionService();
$service->dispatchPrepareEmails($data['id']);
}
public function mytestqq(){
$data = $this->request->post();
$rule = new Validate([
"id"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$service = new PromotionService();
$service->prepareSingleEmail($data['id']);
}
/**
* 定时任务:为指定日期的任务预生成邮件(默认明天)
* 建议每天 22:00 执行curl .../EmailClient/prepareTasksForDate 或 prepareTasksForDate?date=2026-03-12
*/
public function prepareTasksForDate()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d', strtotime('+1 day'));
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->prepareTasksForDate($date);
return jsonSuccess($result);
}
/**
* 定时任务:触发指定日期的已准备任务开始发送(默认今天)
* 建议每天 8:00 执行curl .../EmailClient/triggerTasksForDate 或 triggerTasksForDate?date=2026-03-12
*/
public function triggerTasksForDate()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d');
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->startTasksForDate($date);
return jsonSuccess($result);
}
/**
* Start or resume a promotion task
*/
public function startTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
if ($task['state'] == 3) {
return jsonError('Task already completed');
}
if ($task['state'] == 4) {
return jsonError('Task has been cancelled');
}
if ($task['state'] == 1) {
return jsonError('Task is already running');
}
// state=0 草稿 或 state=5 已准备 均可启动
if ($task['state'] != 0 && $task['state'] != 5) {
return jsonError('Task can only be started when state is draft(0) or prepared(5), current: ' . $task['state']);
}
Db::name('promotion_task')->where('task_id', $taskId)->update([
'state' => 1,
'utime' => time(),
]);
(new PromotionService())->enqueueNextEmail($taskId, 0);
return jsonSuccess([
'task_id' => $taskId,
'state' => 1,
'msg' => 'Task started, emails will be sent via queue',
]);
}
/**
* Pause a running task
*/
public function pauseTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
if ($task['state'] != 1) {
return jsonError('Can only pause a running task (current state: ' . $task['state'] . ')');
}
Db::name('promotion_task')->where('task_id', $taskId)->update([
'state' => 2,
'utime' => time(),
]);
return jsonSuccess(['task_id' => $taskId, 'state' => 2]);
}
/**
* Cancel a task (cannot be resumed)
*/
public function cancelTask()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
if ($task['state'] == 3 || $task['state'] == 4) {
return jsonError('Task already finished/cancelled');
}
Db::name('promotion_task')->where('task_id', $taskId)->update([
'state' => 4,
'utime' => time(),
]);
Db::name('promotion_email_log')
->where('task_id', $taskId)
->where('state', 0)
->update(['state' => 4, 'error_msg' => 'Task cancelled']);
return jsonSuccess(['task_id' => $taskId, 'state' => 4]);
}
/**
* Get task list for a journal
*/
public function getTaskList()
{
$journalId = intval($this->request->param('journal_id', 0));
$factoryId = $this->request->param('factory_id', '');
$type = $this->request->param('type', '');
$state = $this->request->param('state', '-1');
$page = max(1, intval($this->request->param('page', 1)));
$perPage = max(1, min(intval($this->request->param('per_page', 20)), 100));
$where = [];
if ($journalId) {
$where['journal_id'] = $journalId;
}
if ($factoryId !== '' && $factoryId !== '-1') {
$where['factory_id'] = intval($factoryId);
}
if ($type !== '' && $type !== '-1') {
$where['type'] = intval($type);
}
if ($state !== '-1' && $state !== '') {
$where['state'] = intval($state);
}
$total = Db::name('promotion_task')->where($where)->count();
$list = Db::name('promotion_task')
->where($where)
->order('task_id desc')
->page($page, $perPage)
->select();
foreach ($list as &$item) {
$pending = Db::name('promotion_email_log')
->where('task_id', $item['task_id'])
->where('state', 0)
->count();
$item['pending_count'] = $pending;
$item['progress'] = $item['total_count'] > 0
? round(($item['sent_count'] + $item['fail_count']) / $item['total_count'] * 100, 1)
: 0;
$tp = isset($item['target_partitions']) ? trim((string)$item['target_partitions']) : '';
$tc = isset($item['target_country_ids']) ? trim((string)$item['target_country_ids']) : '';
if ($tp === '' && $tc === '') {
$item['country_scope_label'] = '全部国家';
} else {
$parts = [];
if ($tp !== '') {
$parts[] = '分区' . $tp;
}
if ($tc !== '') {
$cids = array_map('intval', explode(',', $tc));
$names = Db::name('country')->where('country_id', 'in', $cids)->column('zh_name');
if (!empty($names)) {
$parts[] = implode(',', $names);
}
}
$item['country_scope_label'] = implode(' + ', $parts);
}
$fid = isset($item['factory_id']) ? intval($item['factory_id']) : 0;
if ($fid > 0) {
$fRow = Db::name('promotion_factory')->where('promotion_factory_id', $fid)->field('promotion_factory_id, type, fetch_ids')->find();
$item['factory_name'] = $fRow ? ('工厂#' . $fid) : '已删除';
} else {
$item['factory_name'] = '手动创建';
}
}
return jsonSuccess([
'list' => $list,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $total > 0 ? ceil($total / $perPage) : 0,
]);
}
/**
* Get task detail with stats
*/
public function getTaskDetail()
{
$taskId = intval($this->request->param('task_id', 0));
if (!$taskId) {
return jsonError('task_id is required');
}
$task = Db::name('promotion_task')->where('task_id', $taskId)->find();
if (!$task) {
return jsonError('Task not found');
}
$stats = Db::name('promotion_email_log')
->field('state, count(*) as cnt')
->where('task_id', $taskId)
->group('state')
->select();
$stateMap = ['pending' => 0, 'sent' => 0, 'failed' => 0, 'bounce' => 0, 'cancelled' => 0];
foreach ($stats as $s) {
switch ($s['state']) {
case 0: $stateMap['pending'] = $s['cnt']; break;
case 1: $stateMap['sent'] = $s['cnt']; break;
case 2: $stateMap['failed'] = $s['cnt']; break;
case 3: $stateMap['bounce'] = $s['cnt']; break;
case 4: $stateMap['cancelled'] = $s['cnt']; break;
}
}
$task['log_stats'] = $stateMap;
$task['progress'] = $task['total_count'] > 0
? round(($task['sent_count'] + $task['fail_count']) / $task['total_count'] * 100, 1)
: 0;
$task['bounce_rate'] = ($task['sent_count'] > 0)
? round($task['bounce_count'] / $task['sent_count'] * 100, 1)
: 0;
return jsonSuccess($task);
}
/**
* Get sending logs for a task
*/
public function getTaskLogs()
{
$taskId = intval($this->request->param('task_id', 0));
$state = $this->request->param('state', '-1');
$page = max(1, intval($this->request->param('page', 1)));
$perPage = max(1, min(intval($this->request->param('per_page', 50)), 200));
if (!$taskId) {
return jsonError('task_id is required');
}
$where = ['l.task_id' => $taskId];
if ($state !== '-1' && $state !== '') {
$where['l.state'] = intval($state);
}
$total = Db::name('promotion_email_log')->alias('l')->where($where)->count();
$list = Db::name('promotion_email_log')->alias('l')
->join('t_expert e', 'l.expert_id = e.expert_id', 'LEFT')
->where($where)
->field('l.*, e.name as expert_name, e.affiliation')
->order('l.log_id asc')
->page($page, $perPage)
->select();
return jsonSuccess([
'list' => $list,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'total_pages' => $total > 0 ? ceil($total / $perPage) : 0,
]);
}
public function getTaskLogDetail(){
$data = $this->request->post();
$rule = new Validate([
"log_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$info = Db::name("promotion_email_log")->where("log_id",$data['log_id'])->find();
$expert_info = Db::name("expert")->where("expert_id",$info['expert_id'])->find();
$expert_info['fields'] = Db::name("expert_field")->where("expert_id",$info['expert_id'])->where("state",0)->select();
return jsonSuccess(['log'=>$info,'expert'=>$expert_info]);
}
public function cancelTaskLog(){
$data = $this->request->post();
$rule = new Validate([
"log_id"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
Db::name("promotion_email_log")->where("log_id",$data['log_id'])->update(['state'=>4]);
return jsonSuccess();
}
public function updateTaskLog(){
$data = $this->request->post();
$rule = new Validate([
"log_id"=>"require",
"subject_prepared"=>"require",
"body_prepared"=>"require"
]);
if(!$rule->check($data)){
return jsonError($rule->getError());
}
$update['subject_prepared']=$data['subject_prepared'];
$update['body_prepared']=$data['body_prepared'];
Db::name("promotion_email_log")->where("log_id",$data['log_id'])->update($update);
$info = Db::name("promotion_email_log")->where("log_id",$data['log_id'])->find();
return jsonSuccess(['log'=>$info]);
}
/**
* Cron: check bounce emails and update promotion logs accordingly
* Should be called periodically after syncInbox runs.
*/
public function syncBounceToLogs()
{
$journalId = intval($this->request->param('journal_id', 0));
$where = ['is_bounce' => 1, 'state' => 0];
if ($journalId) {
$where['journal_id'] = $journalId;
}
$bounces = Db::name('email_inbox')
->where($where)
->where('bounce_email', '<>', '')
->select();
$updated = 0;
foreach ($bounces as $bounce) {
$affected = Db::name('promotion_email_log')
->where('email_to', strtolower($bounce['bounce_email']))
->where('state', 1)
->update(['state' => 3]);
if ($affected > 0) {
$updated += $affected;
$taskIds = Db::name('promotion_email_log')
->where('email_to', strtolower($bounce['bounce_email']))
->where('state', 3)
->column('task_id');
$taskIds = array_unique($taskIds);
foreach ($taskIds as $tid) {
$bounceCount = Db::name('promotion_email_log')
->where('task_id', $tid)
->where('state', 3)
->count();
Db::name('promotion_task')
->where('task_id', $tid)
->update(['bounce_count' => $bounceCount, 'utime' => time()]);
}
}
}
return jsonSuccess(['bounce_logs_updated' => $updated]);
}
// ==================== Cron: Auto Generate Daily Tasks ====================
/**
* 每日自动生成推广任务(由 Linux crontab 调用)
*
* 逻辑:
* 1. 查询所有 state=0 的任务工厂(支持 expert_type=2 编委 / =5 expert 库;其他类型预留)
* 2. JOIN journal 确认期刊有效state=0, start_promotion=1
* 3. 按 factory_id + send_date 检查去重
* 4. template/style: 工厂 > 0 用工厂的,否则用期刊默认
* 5. expert_type=5: 用 fetch_ids/partitions/country 选 t_expert
* expert_type=2: 直接按 journal_id 选 t_board_to_journal频次按 promotion_email_log 同 expert_type 维度扣除
* 6. 生成 promotion_task + promotion_email_log
*
* crontab 示例每天凌晨1点执行
* 0 1 * * * curl -s "https://your-domain.com/api/email_client/cronDailyCreateTasks" >> /var/log/promotion_cron.log 2>&1
*/
public function cronDailyCreateTasks()
{
set_time_limit(120);
$sendDate = date('Y-m-d', strtotime('+1 day'));
$noRepeatDaysDefault = 30;
$factories = Db::name('promotion_factory')
->alias('f')
->join('t_journal j', 'j.journal_id = f.journal_id', 'inner')
->where('f.state', 0)
->where('f.expert_type', 'in', [2, 3, 5])
->where('j.state', 0)
->where('f.start_promotion', 1)
->field('f.*, j.title as journal_title, j.default_template_id, j.default_style_id')
->select();
if (empty($factories)) {
return jsonSuccess(['msg' => 'No active factories found', 'created' => 0]);
}
$created = 0;
$skipped = 0;
$errors = [];
$details = [];
foreach ($factories as $factory) {
$factoryId = intval($factory['promotion_factory_id']);
$journalId = intval($factory['journal_id']);
$existTask = Db::name('promotion_task')
->where('factory_id', $factoryId)
->where('send_date', $sendDate)
->where('state', 'in', [0, 1, 5])
->find();
if ($existTask) {
$skipped++;
continue;
}
$templateId = intval($factory['template_id']) > 0
? intval($factory['template_id'])
: intval($factory['default_template_id']);
$styleId = intval($factory['style_id']) > 0
? intval($factory['style_id'])
: intval(isset($factory['default_style_id']) ? $factory['default_style_id'] : 0);
if ($templateId <= 0) {
$errors[] = 'factory_id=' . $factoryId . ': no template_id configured';
continue;
}
$tpl = Db::name('mail_template')
->where('template_id', $templateId)
->where('journal_id', $journalId)
->where('state', 0)
->find();
if (!$tpl) {
$errors[] = 'factory_id=' . $factoryId . ': template_id=' . $templateId . ' not found';
continue;
}
$smtpIds = trim((string)$factory['email_ids']);
if ($smtpIds === '') {
$smtpCount = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->count();
if ($smtpCount == 0) {
$errors[] = 'factory_id=' . $factoryId . ': no active SMTP account';
continue;
}
}
$expertType = intval($factory['expert_type']);
// 内部受众type∈{1..4}):默认 60 天频次(约稿场景);外部 expert 库type=5沿用 30 天
$noRepeatDays = $expertType === 5 ? $noRepeatDaysDefault : 60;
if ($expertType === 5) {
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
if (empty($fields)) {
$errors[] = 'factory_id=' . $factoryId . ': no valid fields from fetch_ids';
continue;
}
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
} else {
$fields = [];
$targetPartitions = '';
$targetCountryIds = '';
}
$dailyLimit = max(1, intval($factory['send_count']));
$experts = $this->findEligibleExperts(
$fields, $noRepeatDays, $dailyLimit,
$targetPartitions, $targetCountryIds,
$expertType, $journalId
);
if (empty($experts)) {
$details[] = [
'factory_id' => $factoryId,
'journal_id' => $journalId,
'title' => $factory['journal_title'],
'status' => 'no_audience',
];
continue;
}
$now = time();
$scene = $tpl['scene'] ?? 'promotion';
$taskId = Db::name('promotion_task')->insertGetId([
'journal_id' => $journalId,
'factory_id' => $factoryId,
'template_id' => $templateId,
'style_id' => $styleId,
'scene' => $scene,
'type' => intval($factory['type']),
'expert_type' => intval($factory['expert_type']),
'task_name' => 'Auto-' . ($factory['journal_title'] ?? $journalId) . '-F' . $factoryId . '-' . $sendDate,
'total_count' => count($experts),
'sent_count' => 0,
'fail_count' => 0,
'bounce_count' => 0,
'state' => 0,
'smtp_ids' => $smtpIds,
'min_interval' => 30,
'max_interval' => 60,
'max_bounce_rate' => 5,
'no_repeat_days' => $noRepeatDays,
'send_start_hour' => 8,
'send_end_hour' => 22,
'send_date' => $sendDate,
'target_partitions' => $targetPartitions,
'target_country_ids' => $targetCountryIds,
'ctime' => $now,
'utime' => $now,
]);
$logs = [];
foreach ($experts as $expert) {
$logs[] = [
'task_id' => $taskId,
'expert_id' => $expertType === 5 ? intval($expert['expert_id']) : 0,
'user_id' => $expertType === 5 ? 0 : intval($expert['user_id']),
'j_email_id' => 0,
'email_to' => $expert['email'],
'subject' => '',
'state' => 0,
'error_msg' => '',
'send_time' => 0,
'ctime' => $now,
];
}
Db::name('promotion_email_log')->insertAll($logs);
$created++;
$details[] = [
'factory_id' => $factoryId,
'journal_id' => $journalId,
'title' => $factory['journal_title'],
'task_id' => $taskId,
'expert_count' => count($experts),
'send_date' => $sendDate,
];
}
return jsonSuccess([
'send_date' => $sendDate,
'created' => $created,
'skipped' => $skipped,
'errors' => $errors,
'details' => $details,
]);
}
/**
* 每日自动 prepare预生成 subject/body供 crontab 调用
*
* 默认准备明天 send_date 的任务;可传 date=Y-m-d
* 建议每天 22:00 执行curl .../EmailClient/cronDailyPrepareTasks
*/
public function cronDailyPrepareTasks()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d', strtotime('+1 day'));
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->prepareTasksForDate($date);
return jsonSuccess($result);
}
public function testYbpard(){
$service = new PromotionService();
$expert['application_link_yeditorial_board'] = $service->buildYboardApplyUrl(
intval(259116),
intval(3)
);
return jsonSuccess($expert);
}
/**
* 每日自动触发发送(把 prepared 的任务启动),供 crontab 调用
*
* 默认触发今天 send_date 的任务;可传 date=Y-m-d
* 会先对当天 state=0 的任务做一次补 prepare再启动所有 state=5 的任务
* 建议每天 08:00 执行curl .../EmailClient/cronDailyTriggerTasks
*/
public function cronDailyTriggerTasks()
{
$date = trim($this->request->param('date', ''));
if ($date === '') {
$date = date('Y-m-d');
} else {
$ts = strtotime($date);
if ($ts === false) {
return jsonError('date invalid, use Y-m-d');
}
$date = date('Y-m-d', $ts);
}
$service = new PromotionService();
$result = $service->startTasksForDate($date);
return jsonSuccess($result);
}
public function act_task(){
$service = new PromotionService();
$res = $service->processNextEmail(2);
return jsonSuccess(['rr'=>$res]);
}
/**
* 根据 fetch_ids逗号分隔的 expert_fetch_id查出 field 名称数组
*/
private function resolveFieldsByFetchIds($fetchIds)
{
$fetchIds = trim((string)$fetchIds);
if ($fetchIds === '') {
return [];
}
$ids = array_map('intval', explode(',', $fetchIds));
$ids = array_filter($ids);
if (empty($ids)) {
return [];
}
$fields = Db::name('expert_fetch')
->where('expert_fetch_id', 'in', $ids)
->where('state', 0)
->column('field');
return array_values(array_unique(array_filter(array_map('trim', $fields))));
}
/**
* 根据 journal_promotion_field 查出期刊绑定的领域名称数组(旧逻辑兼容)
*/
private function resolveFieldsByJournal($journalId)
{
$fields = Db::name('journal_promotion_field')
->alias('jpf')
->join('t_expert_fetch ef_fetch', 'ef_fetch.expert_fetch_id = jpf.expert_fetch_id', 'inner')
->where('jpf.journal_id', $journalId)
->where('jpf.state', 0)
->where('ef_fetch.state', 0)
->column('ef_fetch.field');
return array_values(array_unique(array_filter(array_map('trim', $fields))));
}
/**
* 根据 target_partitions + target_country_ids 解析出最终要筛选的 country_id 列表
* 两者取并集;都为空则返回空数组,表示不做国家过滤
*/
private function resolveCountryIds($targetPartitions, $targetCountryIds)
{
$ids = [];
if ($targetPartitions !== '') {
$parts = array_map('intval', explode(',', $targetPartitions));
$parts = array_filter($parts);
if (!empty($parts)) {
$partIds = Db::name('country')
->where('partition', 'in', $parts)
->column('country_id');
$ids = array_merge($ids, $partIds);
}
}
if ($targetCountryIds !== '') {
$extra = array_map('intval', explode(',', $targetCountryIds));
$ids = array_merge($ids, $extra);
}
return array_values(array_unique(array_filter($ids)));
}
/**
* 预览某工厂当前可发送的受众(已扣除频次内已发的)
* 仅返回元信息,不创建任务、不修改任何记录。便于上线前肉眼审核。
*
* 入参:
* promotion_factory_id (必填)
* no_repeat_days (可选,默认按 expert_type 取5→30其他→60)
* sample_size (可选,前 N 条样本,默认 10最大 50)
*
* 返回:
* {
* factory_id, journal_id, expert_type, expert_type_label,
* no_repeat_days, daily_limit,
* eligible_count, // 实际可发数(已扣频次)
* samples: [ { name, email, affiliation, ... } ]
* }
*/
public function previewFactoryAudience()
{
$factoryId = intval($this->request->param('promotion_factory_id', 0));
$sampleSize = intval($this->request->param('sample_size', 10));
$sampleSize = max(1, min(50, $sampleSize));
if (!$factoryId) {
return jsonError('promotion_factory_id is required');
}
$factory = Db::name('promotion_factory')->where('promotion_factory_id', $factoryId)->find();
if (!$factory) {
return jsonError('Factory not found');
}
$journalId = intval($factory['journal_id']);
$expertType = intval($factory['expert_type']);
$dailyLimit = max(1, intval($factory['send_count']));
// 默认频次expert=30天内部=60天
$noRepeatDaysDefault = $expertType === 5 ? 30 : 60;
$noRepeatDays = intval($this->request->param('no_repeat_days', $noRepeatDaysDefault));
if ($expertType === 5) {
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
if (empty($fields)) {
return jsonSuccess([
'factory_id' => $factoryId,
'journal_id' => $journalId,
'expert_type' => $expertType,
'expert_type_label' => $this->labelExpertType($expertType),
'no_repeat_days' => $noRepeatDays,
'daily_limit' => $dailyLimit,
'eligible_count' => 0,
'samples' => [],
'note' => 'No valid fields resolved from factory.fetch_ids',
]);
}
} else {
$fields = [];
$targetPartitions = '';
$targetCountryIds = '';
}
// 取一个稍大的窗口用于估算总人数 + 样本(不一定 == dailyLimit
$previewLimit = max($dailyLimit, $sampleSize);
$audience = $this->findEligibleExperts(
$fields, $noRepeatDays, $previewLimit,
$targetPartitions, $targetCountryIds,
$expertType, $journalId
);
$samples = [];
foreach (array_slice($audience, 0, $sampleSize) as $a) {
$samples[] = [
'name' => $a['name'] ?? '',
'email' => $a['email'] ?? '',
'affiliation' => $a['affiliation'] ?? '',
'expert_id' => intval($a['expert_id'] ?? 0),
'user_id' => intval($a['user_id'] ?? 0),
];
}
return jsonSuccess([
'factory_id' => $factoryId,
'journal_id' => $journalId,
'expert_type' => $expertType,
'expert_type_label' => $this->labelExpertType($expertType),
'no_repeat_days' => $noRepeatDays,
'daily_limit' => $dailyLimit,
'eligible_count' => count($audience),
'samples' => $samples,
]);
}
private function labelExpertType($t)
{
$map = [
1 => 'Editor-in-Chief',
2 => 'Editorial Board',
3 => 'Young Editorial Board',
4 => 'Author',
5 => 'Expert Pool',
];
return isset($map[intval($t)]) ? $map[intval($t)] : 'Unknown';
}
/**
* 根据 expert_type 分发选人逻辑
*
* - expert_type = 5从 t_expert 库选人(按领域 / 国家 / 频次)
* - expert_type ∈ {1,2,3,4}:从系统内部表选人(主编/编委/青年编委/作者fields 与国家筛选忽略;
* 频次按 t_promotion_email_log 中相同 expert_type 维度的最近发送时间扣除
*
* 返回行 shape 已对齐:
* - type=5 行包含 e.* 全部字段(含 expert_id、country_id、ltime 等)
* - type∈{1..4} 行至少包含 user_id / expert_id=0 / name / email / affiliation / fields=''
*
* @param array $fields 领域名称数组(仅 type=5 生效)
* @param int $noRepeatDays 同一受众 N 天内不重复
* @param int $limit 最大返回数
* @param string $targetPartitions 国家分区,逗号分隔(仅 type=5 生效)
* @param string $targetCountryIds 单独指定的 country_id逗号分隔仅 type=5 生效)
* @param int $expertType 受众类型;默认 5 兼容老调用
* @param int $journalId 期刊 IDtype∈{1..4} 必填)
* @return array
*/
private function findEligibleExperts($fields, $noRepeatDays, $limit, $targetPartitions = '', $targetCountryIds = '', $expertType = 5, $journalId = 0)
{
$expertType = intval($expertType);
$journalId = intval($journalId);
if ($expertType !== 5) {
return $this->selectInternalAudience($expertType, $journalId, $noRepeatDays, $limit);
}
$fields = array_values(array_unique(array_filter(array_map('trim', $fields))));
if (empty($fields)) {
return [];
}
$query = Db::name('expert')->alias('e')
->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner')
->where('e.state', 0)
->where('e.unsubscribed', 0)
->where('ef.state', 0);
$query->where(function ($q) use ($fields) {
foreach ($fields as $idx => $fieldName) {
if ($idx === 0) {
$q->where('ef.field', 'like', '%' . $fieldName . '%');
} else {
$q->whereOr('ef.field', 'like', '%' . $fieldName . '%');
}
}
});
if ($noRepeatDays > 0) {
$cutoff = time() - ($noRepeatDays * 86400);
$query->where(function ($q) use ($cutoff) {
$q->where('e.ltime', 0)->whereOr('e.ltime', '<', $cutoff);
});
}
$countryIds = $this->resolveCountryIds($targetPartitions, $targetCountryIds);
if (!empty($countryIds)) {
$query->where('e.country_id', 'in', $countryIds);
}
return $query
->field('e.*')
->group('e.expert_id')
->limit($limit)
->select();
}
/**
* 系统内部受众选人(编委 / 主编 / 青年编委 / 作者)
* 仅按 期刊 + 频次 过滤;领域 / 国家无关
*
* 频次:扣除「同 expert_type 维度下no_repeat_days 内已经发出 (state=1) 或退信 (state=3) 的人」
*
* @param int $expertType 1=主编 2=编委 3=青年编委 4=作者
* @param int $journalId
* @param int $noRepeatDays
* @param int $limit
* @return array
*/
private function selectInternalAudience($expertType, $journalId, $noRepeatDays, $limit)
{
if ($journalId <= 0 || $limit <= 0) return [];
switch ($expertType) {
case 2: // 编委
$query = Db::name('board_to_journal')->alias('b')
->join('t_user u', 'u.user_id = b.user_id', 'inner')
->join('t_user_reviewer_info uri', 'uri.reviewer_id = u.user_id', 'left')
->where('b.journal_id', $journalId)
->where('b.state', 0)
->where('u.email', '<>', '')
->where('u.unsubscribed', 0);
break;
case 3: // 青年编委只取当前在任start_date <= now <= end_date
$now = time();
$query = Db::name('user_to_yboard')->alias('y')
->join('t_user u', 'u.user_id = y.user_id', 'inner')
->join('t_user_reviewer_info uri', 'uri.reviewer_id = u.user_id', 'left')
->where('y.journal_id', $journalId)
->where('y.state', 0)
->where('y.start_date', '<=', $now)
->where('y.end_date', '>=', $now)
->where('u.email', '<>', '')
->where('u.unsubscribed', 0);
break;
case 1: // 主编(预留,本期不实现)
case 4: // 作者(预留)
default:
return [];
}
if ($noRepeatDays > 0) {
$cutoff = intval(time() - ($noRepeatDays * 86400));
$expertTypeSafe = intval($expertType);
// 关联子查询:相对于 NOT IN避免把全部已发 user_id 拉到 PHP 再拼回 SQL
// 配合 t_promotion_email_log(user_id, send_time) 复合索引做半连接探针,常量时间。
$query->where(function ($q) use ($expertTypeSafe, $cutoff) {
$q->table('t_promotion_email_log')->alias('l')
->join('t_promotion_task t', 't.task_id = l.task_id', 'inner')
->where('t.expert_type', $expertTypeSafe)
->where('l.state', 'in', [1, 3])
->where('l.send_time', '>', $cutoff)
->whereRaw('l.user_id = u.user_id');
}, 'not exists');
}
$rows = $query
->field('u.user_id, u.email, u.realname AS name, IFNULL(uri.company, "") AS affiliation')
->group('u.user_id')
->limit($limit)
->select();
// 对齐外部 expert 行 shape下游无需再做分支判空
foreach ($rows as &$r) {
$r['expert_id'] = 0;
$r['fields'] = '';
$r['country_id'] = 0;
}
unset($r);
return $rows;
}
private function publishInboxUpdatedEvent($accountId, $payload)
{
$file = $this->getInboxSseEventFile($accountId);
$dir = dirname($file);
if (!is_dir($dir)) {
@mkdir($dir, 0777, true);
}
@file_put_contents($file, json_encode($payload, JSON_UNESCAPED_UNICODE));
}
private function getInboxSseEventFile($accountId)
{
return ROOT_PATH . 'runtime' . DS . 'sse' . DS . 'inbox_email_' . intval($accountId) . '.json';
}
// ==================== Internal Methods ====================
/**
* Pick the best available SMTP account for a journal
* Strategy: choose the account with the most remaining quota
*/
private function pickSmtpAccount($journalId)
{
$accounts = Db::name('journal_email')
->where('journal_id', $journalId)
->where('state', 0)
->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;
}
/**
* Reset today_sent if the date has changed
*/
private function resetDailyCountIfNeeded(&$account)
{
$todayDate = date('Y-m-d');
$cacheKey = 'smtp_reset_' . $account['j_email_id'];
$lastReset = \think\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;
\think\Cache::set($cacheKey, $todayDate, 86400);
}
}
/**
* Actually send an email using PHPMailer with given SMTP config
*/
private 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()];
}
}
/**
* Replace template variables with expert data
*/
private function replaceVariables($template, $expert)
{
$vars = [
'{name}' => $expert['name'] ?? '',
'{email}' => $expert['email'] ?? '',
'{affiliation}' => $expert['affiliation'] ?? '',
'{field}' => $expert['field'] ?? '',
];
return str_replace(array_keys($vars), array_values($vars), $template);
}
private function buildExpertVars($expert)
{
$email = (string)($expert['email'] ?? '');
$unsubUrl = '';
if ($email !== '') {
if (!empty($expert['expert_id'])) {
$unsubUrl = \app\common\UnsubscribeService::buildUrl('expert', intval($expert['expert_id']), $email);
} elseif (!empty($expert['user_id'])) {
$unsubUrl = \app\common\UnsubscribeService::buildUrl('user', intval($expert['user_id']), $email);
}
}
return [
'name' => $expert['name'] ?? '',
'email' => $expert['email'] ?? '',
'affiliation' => $expert['affiliation'] ?? '',
'field' => $expert['field'] ?? '',
'unsubscribe_url' => $unsubUrl,
'application_link_yeditorial_board' => $expert['application_link_yeditorial_board'] ?? '',
];
}
private function buildJournalVars($journal)
{
if (!$journal) return [];
return [
'journal_title' => $journal['title'] ?? '',
'journal_abbr' => $journal['jabbr'] ?? '',
'journal_url' => $journal['website'] ?? '',
];
}
private function renderVars($tpl, $vars)
{
if (!is_string($tpl) || $tpl === '') return '';
if (!is_array($vars) || empty($vars)) return $tpl;
$map = [];
foreach ($vars as $k => $v) {
$key = trim((string)$k);
if ($key === '') continue;
$map[$key] = (string)$v;
}
if (empty($map)) return $tpl;
// 双大括号:允许内部有空格,如 {{ var }} / {{ var }}
$tpl = preg_replace_callback('/\{\{\s*([A-Za-z0-9_\-\.]+)\s*\}\}/', function ($m) use ($map) {
return array_key_exists($m[1], $map) ? $map[$m[1]] : $m[0];
}, $tpl);
// 单大括号:保持严格匹配(不允许内部空格),避免误伤正文
$single = [];
foreach ($map as $k => $v) {
$single['{' . $k . '}'] = $v;
}
return str_replace(array_keys($single), array_values($single), $tpl);
}
private 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;
// 新版 style 表:只使用 header_html / footer_html 作为整体风格包裹
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]];
}
}