自动推广
This commit is contained in:
@@ -4,8 +4,11 @@ namespace app\api\controller;
|
|||||||
|
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use think\Env;
|
use think\Env;
|
||||||
use PHPMailer\PHPMailer;
|
use think\Cache;
|
||||||
|
use think\Queue;
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
use think\Validate;
|
use think\Validate;
|
||||||
|
use app\common\PromotionService;
|
||||||
|
|
||||||
class EmailClient extends Base
|
class EmailClient extends Base
|
||||||
{
|
{
|
||||||
@@ -173,7 +176,10 @@ class EmailClient extends Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a single email using a specific journal's SMTP
|
* Send a single email using a specific journal's SMTP
|
||||||
* Params: journal_id, to_email, subject, content, j_email_id(optional, auto-select if empty)
|
* 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()
|
public function sendOne()
|
||||||
{
|
{
|
||||||
@@ -181,10 +187,26 @@ class EmailClient extends Base
|
|||||||
$toEmail = trim($this->request->param('to_email', ''));
|
$toEmail = trim($this->request->param('to_email', ''));
|
||||||
$subject = trim($this->request->param('subject', ''));
|
$subject = trim($this->request->param('subject', ''));
|
||||||
$content = $this->request->param('content', '');
|
$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));
|
$accountId = intval($this->request->param('j_email_id', 0));
|
||||||
|
|
||||||
if (!$journalId || empty($toEmail) || empty($subject) || empty($content)) {
|
if (!$journalId || empty($toEmail)) {
|
||||||
return jsonError('journal_id, to_email, subject, content are required');
|
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) {
|
if ($accountId) {
|
||||||
@@ -227,18 +249,27 @@ class EmailClient extends Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send batch emails to multiple experts
|
* Send batch emails to multiple experts
|
||||||
* Params: journal_id, expert_ids (comma separated), subject, content
|
* Params: journal_id, expert_ids (comma separated),
|
||||||
* Content supports variables: {name}, {email}, {affiliation}
|
* - 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()
|
public function sendBatch()
|
||||||
{
|
{
|
||||||
$journalId = intval($this->request->param('journal_id', 0));
|
$journalId = intval($this->request->param('journal_id', 0));
|
||||||
$expertIds = trim($this->request->param('expert_ids', ''));
|
$expertIds = trim($this->request->param('expert_ids', ''));
|
||||||
$subject = trim($this->request->param('subject', ''));
|
$subject = trim($this->request->param('subject', ''));
|
||||||
$content = $this->request->param('content', '');
|
$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) || empty($subject) || empty($content)) {
|
if (!$journalId || empty($expertIds)) {
|
||||||
return jsonError('journal_id, expert_ids, subject, content are required');
|
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));
|
$ids = array_map('intval', explode(',', $expertIds));
|
||||||
@@ -253,6 +284,9 @@ class EmailClient extends Base
|
|||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$errors = [];
|
$errors = [];
|
||||||
|
|
||||||
|
$journal = Db::name('journal')->where('journal_id', $journalId)->find();
|
||||||
|
$journalVars = $this->buildJournalVars($journal);
|
||||||
|
|
||||||
foreach ($experts as $expert) {
|
foreach ($experts as $expert) {
|
||||||
$account = $this->pickSmtpAccount($journalId);
|
$account = $this->pickSmtpAccount($journalId);
|
||||||
if (!$account) {
|
if (!$account) {
|
||||||
@@ -261,14 +295,28 @@ class EmailClient extends Base
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
$personalContent = $this->replaceVariables($content, $expert);
|
$expertVars = $this->buildExpertVars($expert);
|
||||||
$personalSubject = $this->replaceVariables($subject, $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);
|
$result = $this->doSendEmail($account, $expert['email'], $personalSubject, $personalContent);
|
||||||
|
|
||||||
if ($result['status'] === 1) {
|
if ($result['status'] === 1) {
|
||||||
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
|
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]);
|
Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, 'ltime' => time()]);
|
||||||
$sent++;
|
$sent++;
|
||||||
} else {
|
} else {
|
||||||
$failed++;
|
$failed++;
|
||||||
@@ -1023,6 +1071,404 @@ class EmailClient extends Base
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Promotion Tasks ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a promotion sending task
|
||||||
|
* Params:
|
||||||
|
* - journal_id, template_id, style_id, scene, task_name
|
||||||
|
* - expert_ids (comma separated) OR field + major_id (auto-query from DB)
|
||||||
|
* - smtp_ids (comma separated, optional: restrict to specific SMTP accounts)
|
||||||
|
* - min_interval, max_interval (seconds between emails)
|
||||||
|
* - max_bounce_rate (%), no_repeat_days
|
||||||
|
* - send_start_hour, send_end_hour (UTC, default 8-22)
|
||||||
|
*/
|
||||||
|
public function createTask()
|
||||||
|
{
|
||||||
|
$journalId = intval($this->request->param('journal_id', 0));
|
||||||
|
$templateId = intval($this->request->param('template_id', 0));
|
||||||
|
$styleId = intval($this->request->param('style_id', 0));
|
||||||
|
$scene = trim($this->request->param('scene', ''));
|
||||||
|
$taskName = trim($this->request->param('task_name', ''));
|
||||||
|
$expertIds = trim($this->request->param('expert_ids', ''));
|
||||||
|
$field = trim($this->request->param('field', ''));
|
||||||
|
$majorId = intval($this->request->param('major_id', 0));
|
||||||
|
$smtpIds = trim($this->request->param('smtp_ids', ''));
|
||||||
|
$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));
|
||||||
|
$noRepeatDays = intval($this->request->param('no_repeat_days', 30));
|
||||||
|
$sendStartHour = intval($this->request->param('send_start_hour', 8));
|
||||||
|
$sendEndHour = intval($this->request->param('send_end_hour', 22));
|
||||||
|
|
||||||
|
if (!$journalId || !$templateId) {
|
||||||
|
return jsonError('journal_id and template_id are required');
|
||||||
|
}
|
||||||
|
if (empty($expertIds) && empty($field)) {
|
||||||
|
return jsonError('expert_ids or field is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$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');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($scene)) {
|
||||||
|
$scene = $tpl['scene'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$experts = [];
|
||||||
|
if (!empty($expertIds)) {
|
||||||
|
$ids = array_map('intval', explode(',', $expertIds));
|
||||||
|
$experts = Db::name('expert')->where('expert_id', 'in', $ids)->where('state', 0)->select();
|
||||||
|
} else {
|
||||||
|
$query = Db::name('expert')->alias('e')
|
||||||
|
->join('t_expert_field ef', 'e.expert_id = ef.expert_id')
|
||||||
|
->where('e.state', 0)
|
||||||
|
->where('ef.state', 0);
|
||||||
|
|
||||||
|
if ($field) {
|
||||||
|
$query->where('ef.field', 'like', '%' . $field . '%');
|
||||||
|
}
|
||||||
|
if ($majorId) {
|
||||||
|
$query->where('ef.major_id', $majorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$experts = $query->field('e.*')->group('e.expert_id')->select();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($noRepeatDays > 0) {
|
||||||
|
$cutoff = time() - ($noRepeatDays * 86400);
|
||||||
|
$experts = array_filter($experts, function ($e) use ($cutoff) {
|
||||||
|
return intval($e['ltime']) < $cutoff;
|
||||||
|
});
|
||||||
|
$experts = array_values($experts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($experts)) {
|
||||||
|
return jsonError('No eligible experts found (all may have been promoted recently)');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$taskId = Db::name('promotion_task')->insertGetId([
|
||||||
|
'journal_id' => $journalId,
|
||||||
|
'template_id' => $templateId,
|
||||||
|
'style_id' => $styleId,
|
||||||
|
'scene' => $scene,
|
||||||
|
'task_name' => $taskName ?: ('Task ' . date('Y-m-d H:i')),
|
||||||
|
'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,
|
||||||
|
'ctime' => $now,
|
||||||
|
'utime' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$logs = [];
|
||||||
|
foreach ($experts as $expert) {
|
||||||
|
$logs[] = [
|
||||||
|
'task_id' => $taskId,
|
||||||
|
'expert_id' => intval($expert['expert_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);
|
||||||
|
|
||||||
|
return jsonSuccess([
|
||||||
|
'task_id' => $taskId,
|
||||||
|
'total_count' => count($experts),
|
||||||
|
'state' => 0,
|
||||||
|
'msg' => 'Task created, call startTask to begin sending',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
$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 ($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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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]);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Internal Methods ====================
|
// ==================== Internal Methods ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1079,7 +1525,7 @@ class EmailClient extends Base
|
|||||||
private function doSendEmail($account, $toEmail, $subject, $htmlContent)
|
private function doSendEmail($account, $toEmail, $subject, $htmlContent)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$mail = new PHPMailer\PHPMailer(true);
|
$mail = new PHPMailer(true);
|
||||||
$mail->isSMTP();
|
$mail->isSMTP();
|
||||||
$mail->SMTPDebug = 0;
|
$mail->SMTPDebug = 0;
|
||||||
$mail->CharSet = 'UTF-8';
|
$mail->CharSet = 'UTF-8';
|
||||||
@@ -1130,4 +1576,69 @@ class EmailClient extends Base
|
|||||||
|
|
||||||
return str_replace(array_keys($vars), array_values($vars), $template);
|
return str_replace(array_keys($vars), array_values($vars), $template);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildExpertVars($expert)
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $expert['name'] ?? '',
|
||||||
|
'email' => $expert['email'] ?? '',
|
||||||
|
'affiliation' => $expert['affiliation'] ?? '',
|
||||||
|
'field' => $expert['field'] ?? '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,31 +4,21 @@ namespace app\api\controller;
|
|||||||
|
|
||||||
use think\Cache;
|
use think\Cache;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
use GuzzleHttp\Client;
|
use think\Validate;
|
||||||
|
use app\common\ExpertFinderService;
|
||||||
|
|
||||||
class ExpertFinder extends Base
|
class ExpertFinder extends Base
|
||||||
{
|
{
|
||||||
private $httpClient;
|
private $service;
|
||||||
private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
|
||||||
|
|
||||||
public function __construct(\think\Request $request = null)
|
public function __construct(\think\Request $request = null)
|
||||||
{
|
{
|
||||||
parent::__construct($request);
|
parent::__construct($request);
|
||||||
$this->httpClient = new Client([
|
$this->service = new ExpertFinderService();
|
||||||
'timeout' => 180,
|
|
||||||
'connect_timeout' => 15,
|
|
||||||
'verify' => false,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main search endpoint
|
* Main search endpoint
|
||||||
* Params:
|
|
||||||
* keyword - search term (e.g. "biomedical engineering")
|
|
||||||
* page - page number, default 1
|
|
||||||
* per_page - articles per page, default 100, max 100
|
|
||||||
* min_year - earliest publication year, default current-3
|
|
||||||
* source - "pubmed" (fast, email from affiliation) or "pmc" (slower, structured email)
|
|
||||||
*/
|
*/
|
||||||
public function search()
|
public function search()
|
||||||
{
|
{
|
||||||
@@ -49,16 +39,12 @@ class ExpertFinder extends Base
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($source === 'pmc') {
|
$result = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source);
|
||||||
$result = $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
|
||||||
} else {
|
|
||||||
$result = $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return jsonError('Search failed: ' . $e->getMessage());
|
return jsonError('Search failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
$saveResult = $this->saveExperts($result['experts'], $keyword, $source);
|
$saveResult = $this->service->saveExperts($result['experts'], $keyword, $source);
|
||||||
$result['saved_new'] = $saveResult['inserted'];
|
$result['saved_new'] = $saveResult['inserted'];
|
||||||
$result['saved_exist'] = $saveResult['existing'];
|
$result['saved_exist'] = $saveResult['existing'];
|
||||||
|
|
||||||
@@ -69,16 +55,6 @@ class ExpertFinder extends Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get experts from local database
|
* Get experts from local database
|
||||||
* Params:
|
|
||||||
* field - filter by field keyword (searches t_expert_field)
|
|
||||||
* major_id - filter by major_id (searches t_expert_field)
|
|
||||||
* state - filter by state (0-5), -1 for all
|
|
||||||
* keyword - search name/email/affiliation
|
|
||||||
* no_recent - if 1, exclude experts promoted within last N days (default 30)
|
|
||||||
* recent_days - days threshold for no_recent filter, default 30
|
|
||||||
* page - page number, default 1
|
|
||||||
* per_page - items per page, default 20
|
|
||||||
* min_experts - auto-fetch threshold, default 50
|
|
||||||
*/
|
*/
|
||||||
public function getList()
|
public function getList()
|
||||||
{
|
{
|
||||||
@@ -171,9 +147,6 @@ class ExpertFinder extends Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Update expert state
|
* Update expert state
|
||||||
* Params:
|
|
||||||
* expert_id - single id or comma-separated ids
|
|
||||||
* state - new state (0-5)
|
|
||||||
*/
|
*/
|
||||||
public function updateState()
|
public function updateState()
|
||||||
{
|
{
|
||||||
@@ -195,9 +168,6 @@ class ExpertFinder extends Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete expert (soft: set state=5 blacklist, or hard delete)
|
* Delete expert (soft: set state=5 blacklist, or hard delete)
|
||||||
* Params:
|
|
||||||
* expert_id - single id or comma-separated ids
|
|
||||||
* hard - 1 for hard delete, default 0 (blacklist)
|
|
||||||
*/
|
*/
|
||||||
public function deleteExpert()
|
public function deleteExpert()
|
||||||
{
|
{
|
||||||
@@ -221,7 +191,6 @@ class ExpertFinder extends Base
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Export search results to Excel
|
* Export search results to Excel
|
||||||
* Same params as search(), exports current page results
|
|
||||||
*/
|
*/
|
||||||
public function export()
|
public function export()
|
||||||
{
|
{
|
||||||
@@ -240,11 +209,7 @@ class ExpertFinder extends Base
|
|||||||
|
|
||||||
if (!$cached) {
|
if (!$cached) {
|
||||||
try {
|
try {
|
||||||
if ($source === 'pmc') {
|
$cached = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source);
|
||||||
$cached = $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
|
||||||
} else {
|
|
||||||
$cached = $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
|
||||||
}
|
|
||||||
Cache::set($cacheKey, $cached, 3600);
|
Cache::set($cacheKey, $cached, 3600);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return jsonError('Search failed: ' . $e->getMessage());
|
return jsonError('Search failed: ' . $e->getMessage());
|
||||||
@@ -276,16 +241,82 @@ class ExpertFinder extends Base
|
|||||||
|
|
||||||
// ==================== Cron / Auto Fetch ====================
|
// ==================== Cron / Auto Fetch ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily cron: auto-fetch experts for every journal's fields via queue
|
||||||
|
*/
|
||||||
|
public function dailyFetchAll()
|
||||||
|
{
|
||||||
|
$journalId = intval($this->request->param('journal_id', 0));
|
||||||
|
$perPage = max(10, intval($this->request->param('per_page', 200)));
|
||||||
|
$source = $this->request->param('source', 'pubmed');
|
||||||
|
$minYear = intval($this->request->param('min_year', date('Y') - 3));
|
||||||
|
|
||||||
|
if ($journalId) {
|
||||||
|
$journals = Db::name('journal')->field("journal_id,issn,title")->where('journal_id', $journalId)->select();
|
||||||
|
} else {
|
||||||
|
$journals = Db::name('journal')->field("journal_id,issn,title")->where('state', 0)->select();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($journals)) {
|
||||||
|
return jsonSuccess(['msg' => 'No active journals found', 'queued' => 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$queued = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
$details = [];
|
||||||
|
$todayStart = strtotime(date('Y-m-d'));
|
||||||
|
foreach ($journals as $journal) {
|
||||||
|
$issn = trim($journal['issn'] ?? '');
|
||||||
|
if (empty($issn)) continue;
|
||||||
|
|
||||||
|
$majors = Db::name('major_to_journal')
|
||||||
|
->alias('mtj')
|
||||||
|
->join('t_major m', 'm.major_id = mtj.major_id', 'left')
|
||||||
|
->where('mtj.journal_issn', $issn)
|
||||||
|
->where('mtj.mtj_state', 0)
|
||||||
|
->where("m.pid", "<>", 0)
|
||||||
|
->where('m.major_state', 0)
|
||||||
|
->column('m.major_title');
|
||||||
|
|
||||||
|
$majors = array_unique(array_filter($majors));
|
||||||
|
if (empty($majors)) continue;
|
||||||
|
foreach ($majors as $keyword) {
|
||||||
|
$keyword = trim($keyword);
|
||||||
|
if (empty($keyword)) continue;
|
||||||
|
|
||||||
|
$fetchLog = $this->service->getFetchLog($keyword, $source);
|
||||||
|
if ($fetchLog['last_time'] >= $todayStart) {
|
||||||
|
$skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$delay = $queued * 10;
|
||||||
|
\think\Queue::later($delay, 'app\api\job\FetchExperts@fire', [
|
||||||
|
'field' => $keyword,
|
||||||
|
'source' => $source,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'min_year' => $minYear,
|
||||||
|
'journal_id' => $journal['journal_id'],
|
||||||
|
], 'FetchExperts');
|
||||||
|
|
||||||
|
$queued++;
|
||||||
|
$details[] = [
|
||||||
|
'journal' => $journal['title'] ?? $journal['journal_id'],
|
||||||
|
'keyword' => $keyword,
|
||||||
|
'delay_s' => $delay,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonSuccess([
|
||||||
|
'queued' => $queued,
|
||||||
|
'skipped' => $skipped,
|
||||||
|
'details' => $details,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cron job: daily fetch experts for given keywords
|
* Cron job: daily fetch experts for given keywords
|
||||||
* Params:
|
|
||||||
* keywords - comma-separated keywords (e.g. "biomedical engineering,tissue engineering")
|
|
||||||
* source - pubmed or pmc, default pubmed
|
|
||||||
* per_page - articles per page, default 100
|
|
||||||
* min_year - default current-3
|
|
||||||
*
|
|
||||||
* Uses cache to remember which page was last fetched per keyword,
|
|
||||||
* so each cron run fetches the next page automatically.
|
|
||||||
*/
|
*/
|
||||||
public function cronFetch()
|
public function cronFetch()
|
||||||
{
|
{
|
||||||
@@ -303,39 +334,14 @@ class ExpertFinder extends Base
|
|||||||
$report = [];
|
$report = [];
|
||||||
|
|
||||||
foreach ($keywords as $kw) {
|
foreach ($keywords as $kw) {
|
||||||
if (empty($kw)) {
|
if (empty($kw)) continue;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$fetchLog = $this->getFetchLog($kw, $source);
|
|
||||||
$page = $fetchLog['last_page'] + 1;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($source === 'pmc') {
|
$result = $this->service->doFetchForField($kw, $source, $perPage, $minYear);
|
||||||
$result = $this->searchViaPMC($kw, $perPage, $minYear, $page);
|
$report[] = $result;
|
||||||
} else {
|
|
||||||
$result = $this->searchViaPubMed($kw, $perPage, $minYear, $page);
|
|
||||||
}
|
|
||||||
|
|
||||||
$saveResult = $this->saveExperts($result['experts'], $kw, $source);
|
|
||||||
|
|
||||||
$nextPage = $result['has_more'] ? $page : 0;
|
|
||||||
$totalPages = isset($result['total_pages']) ? $result['total_pages'] : 0;
|
|
||||||
$this->updateFetchLog($kw, $source, $nextPage, $totalPages);
|
|
||||||
|
|
||||||
$report[] = [
|
|
||||||
'keyword' => $kw,
|
|
||||||
'page' => $page,
|
|
||||||
'experts_found' => $result['total'],
|
|
||||||
'saved_new' => $saveResult['inserted'],
|
|
||||||
'saved_exist' => $saveResult['existing'],
|
|
||||||
'field_enriched' => $saveResult['field_enriched'],
|
|
||||||
'has_more' => $result['has_more'],
|
|
||||||
];
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$report[] = [
|
$report[] = [
|
||||||
'keyword' => $kw,
|
'keyword' => $kw,
|
||||||
'page' => $page,
|
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -357,7 +363,7 @@ class ExpertFinder extends Base
|
|||||||
}
|
}
|
||||||
Cache::set($lockKey, 1, 300);
|
Cache::set($lockKey, 1, 300);
|
||||||
|
|
||||||
\think\Queue::push('app\api\job\FetchExperts', [
|
\think\Queue::push('app\api\job\FetchExperts@fire', [
|
||||||
'field' => $field,
|
'field' => $field,
|
||||||
'source' => 'pubmed',
|
'source' => 'pubmed',
|
||||||
'per_page' => 100,
|
'per_page' => 100,
|
||||||
@@ -365,459 +371,17 @@ class ExpertFinder extends Base
|
|||||||
], 'FetchExperts');
|
], 'FetchExperts');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public function mytest()
|
||||||
* Internal method: run a fetch for a single keyword (used by both cron and queue job)
|
|
||||||
*/
|
|
||||||
public function doFetchForField($field, $source = 'pubmed', $perPage = 100, $minYear = null)
|
|
||||||
{
|
{
|
||||||
if ($minYear === null) {
|
$data = $this->request->post();
|
||||||
$minYear = date('Y') - 3;
|
$rule = new Validate([
|
||||||
}
|
"field" => "require"
|
||||||
|
|
||||||
$fetchLog = $this->getFetchLog($field, $source);
|
|
||||||
$page = $fetchLog['last_page'] + 1;
|
|
||||||
|
|
||||||
if ($source === 'pmc') {
|
|
||||||
$result = $this->searchViaPMC($field, $perPage, $minYear, $page);
|
|
||||||
} else {
|
|
||||||
$result = $this->searchViaPubMed($field, $perPage, $minYear, $page);
|
|
||||||
}
|
|
||||||
|
|
||||||
$saveResult = $this->saveExperts($result['experts'], $field, $source);
|
|
||||||
|
|
||||||
$nextPage = $result['has_more'] ? $page : 0;
|
|
||||||
$totalPages = isset($result['total_pages']) ? $result['total_pages'] : 0;
|
|
||||||
$this->updateFetchLog($field, $source, $nextPage, $totalPages);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'keyword' => $field,
|
|
||||||
'page' => $page,
|
|
||||||
'experts_found' => $result['total'],
|
|
||||||
'saved_new' => $saveResult['inserted'],
|
|
||||||
'saved_exist' => $saveResult['existing'],
|
|
||||||
'field_enriched' => $saveResult['field_enriched'],
|
|
||||||
'has_more' => $result['has_more'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== PubMed Search ====================
|
|
||||||
|
|
||||||
private function searchViaPubMed($keyword, $perPage, $minYear, $page = 1)
|
|
||||||
{
|
|
||||||
set_time_limit(600);
|
|
||||||
|
|
||||||
$searchResult = $this->esearch('pubmed', $keyword, $perPage, $minYear, $page);
|
|
||||||
$ids = $searchResult['ids'];
|
|
||||||
$totalArticles = $searchResult['total'];
|
|
||||||
|
|
||||||
if (empty($ids)) {
|
|
||||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pubmed');
|
|
||||||
}
|
|
||||||
|
|
||||||
$allAuthors = [];
|
|
||||||
$batches = array_chunk($ids, 50);
|
|
||||||
foreach ($batches as $batch) {
|
|
||||||
$xml = $this->efetchWithRetry('pubmed', $batch);
|
|
||||||
if ($xml) {
|
|
||||||
$authors = $this->parsePubMedXml($xml);
|
|
||||||
$allAuthors = array_merge($allAuthors, $authors);
|
|
||||||
}
|
|
||||||
usleep(400000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$experts = $this->aggregateExperts($allAuthors);
|
|
||||||
|
|
||||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pubmed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== PMC Search ====================
|
|
||||||
|
|
||||||
private function searchViaPMC($keyword, $perPage, $minYear, $page = 1)
|
|
||||||
{
|
|
||||||
set_time_limit(600);
|
|
||||||
|
|
||||||
$searchResult = $this->esearch('pmc', $keyword, $perPage, $minYear, $page);
|
|
||||||
$ids = $searchResult['ids'];
|
|
||||||
$totalArticles = $searchResult['total'];
|
|
||||||
|
|
||||||
if (empty($ids)) {
|
|
||||||
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pmc');
|
|
||||||
}
|
|
||||||
|
|
||||||
$allAuthors = [];
|
|
||||||
$batches = array_chunk($ids, 5);
|
|
||||||
foreach ($batches as $batch) {
|
|
||||||
$xml = $this->efetchWithRetry('pmc', $batch);
|
|
||||||
if ($xml) {
|
|
||||||
$authors = $this->parsePMCXml($xml);
|
|
||||||
$allAuthors = array_merge($allAuthors, $authors);
|
|
||||||
}
|
|
||||||
usleep(500000);
|
|
||||||
}
|
|
||||||
|
|
||||||
$experts = $this->aggregateExperts($allAuthors);
|
|
||||||
|
|
||||||
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pmc');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== NCBI API Calls ====================
|
|
||||||
|
|
||||||
private function esearch($db, $keyword, $perPage, $minYear, $page = 1)
|
|
||||||
{
|
|
||||||
$term = $keyword . ' AND ' . $minYear . ':' . date('Y') . '[pdat]';
|
|
||||||
$retstart = ($page - 1) * $perPage;
|
|
||||||
|
|
||||||
$response = $this->httpClient->get($this->ncbiBaseUrl . 'esearch.fcgi', [
|
|
||||||
'query' => [
|
|
||||||
'db' => $db,
|
|
||||||
'term' => $term,
|
|
||||||
'retstart' => $retstart,
|
|
||||||
'retmax' => $perPage,
|
|
||||||
'retmode' => 'json',
|
|
||||||
'sort' => 'relevance',
|
|
||||||
],
|
|
||||||
]);
|
]);
|
||||||
|
if (!$rule->check($data)) {
|
||||||
$data = json_decode($response->getBody()->getContents(), true);
|
return jsonError($rule->getError());
|
||||||
$ids = $data['esearchresult']['idlist'] ?? [];
|
|
||||||
$total = intval($data['esearchresult']['count'] ?? 0);
|
|
||||||
|
|
||||||
return ['ids' => $ids, 'total' => $total];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function efetch($db, $ids)
|
|
||||||
{
|
|
||||||
$response = $this->httpClient->post($this->ncbiBaseUrl . 'efetch.fcgi', [
|
|
||||||
'form_params' => [
|
|
||||||
'db' => $db,
|
|
||||||
'id' => implode(',', $ids),
|
|
||||||
'retmode' => 'xml',
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $response->getBody()->getContents();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function efetchWithRetry($db, $ids, $maxRetries = 3)
|
|
||||||
{
|
|
||||||
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
|
||||||
try {
|
|
||||||
return $this->efetch($db, $ids);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
if ($attempt === $maxRetries) {
|
|
||||||
if (count($ids) > 1) {
|
|
||||||
$half = ceil(count($ids) / 2);
|
|
||||||
$firstHalf = array_slice($ids, 0, $half);
|
|
||||||
$secondHalf = array_slice($ids, $half);
|
|
||||||
$xml1 = $this->efetchWithRetry($db, $firstHalf, 2);
|
|
||||||
$xml2 = $this->efetchWithRetry($db, $secondHalf, 2);
|
|
||||||
return $this->mergeXml($xml1, $xml2);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
sleep($attempt * 2);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null;
|
$res = $this->service->doFetchForField($data['field'], "pubmed", 100, date('Y') - 3);
|
||||||
}
|
return jsonSuccess($res);
|
||||||
|
|
||||||
private function mergeXml($xml1, $xml2)
|
|
||||||
{
|
|
||||||
if (empty($xml1)) return $xml2;
|
|
||||||
if (empty($xml2)) return $xml1;
|
|
||||||
return $xml1 . "\n" . $xml2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== PubMed XML Parsing ====================
|
|
||||||
|
|
||||||
private function parsePubMedXml($xmlString)
|
|
||||||
{
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
libxml_use_internal_errors(true);
|
|
||||||
$xml = simplexml_load_string($xmlString);
|
|
||||||
if ($xml === false) {
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($xml->PubmedArticle as $article) {
|
|
||||||
$citation = $article->MedlineCitation;
|
|
||||||
$articleData = $citation->Article;
|
|
||||||
|
|
||||||
$title = $this->xmlNodeToString($articleData->ArticleTitle);
|
|
||||||
$pmid = (string) $citation->PMID;
|
|
||||||
|
|
||||||
$journal = '';
|
|
||||||
if (isset($articleData->Journal->Title)) {
|
|
||||||
$journal = (string) $articleData->Journal->Title;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($articleData->AuthorList->Author)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($articleData->AuthorList->Author as $author) {
|
|
||||||
$lastName = (string) ($author->LastName ?? '');
|
|
||||||
$foreName = (string) ($author->ForeName ?? '');
|
|
||||||
$fullName = trim($foreName . ' ' . $lastName);
|
|
||||||
|
|
||||||
if (empty($fullName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = '';
|
|
||||||
$affiliation = '';
|
|
||||||
|
|
||||||
if (isset($author->AffiliationInfo)) {
|
|
||||||
foreach ($author->AffiliationInfo as $affInfo) {
|
|
||||||
$affText = (string) $affInfo->Affiliation;
|
|
||||||
if (empty($affiliation)) {
|
|
||||||
$affiliation = $affText;
|
|
||||||
}
|
|
||||||
if (empty($email)) {
|
|
||||||
$email = $this->extractEmailFromText($affText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($email)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'name' => $fullName,
|
|
||||||
'email' => strtolower($email),
|
|
||||||
'affiliation' => $this->cleanAffiliation($affiliation),
|
|
||||||
'article_title' => $title,
|
|
||||||
'article_id' => $pmid,
|
|
||||||
'journal' => $journal,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== PMC XML Parsing ====================
|
|
||||||
|
|
||||||
private function parsePMCXml($xmlString)
|
|
||||||
{
|
|
||||||
$results = [];
|
|
||||||
|
|
||||||
libxml_use_internal_errors(true);
|
|
||||||
$xml = simplexml_load_string($xmlString);
|
|
||||||
if ($xml === false) {
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
$articles = $xml->article ?? $xml->children();
|
|
||||||
|
|
||||||
foreach ($articles as $article) {
|
|
||||||
if ($article->getName() !== 'article') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$front = $article->front;
|
|
||||||
if (!$front) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$articleMeta = $front->{'article-meta'};
|
|
||||||
if (!$articleMeta) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$title = $this->xmlNodeToString($articleMeta->{'title-group'}->{'article-title'} ?? null);
|
|
||||||
$pmcId = '';
|
|
||||||
if (isset($articleMeta->{'article-id'})) {
|
|
||||||
foreach ($articleMeta->{'article-id'} as $idNode) {
|
|
||||||
if ((string) $idNode['pub-id-type'] === 'pmc') {
|
|
||||||
$pmcId = (string) $idNode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$journal = '';
|
|
||||||
if (isset($front->{'journal-meta'}->{'journal-title'})) {
|
|
||||||
$journal = (string) $front->{'journal-meta'}->{'journal-title'};
|
|
||||||
} elseif (isset($front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'})) {
|
|
||||||
$journal = (string) $front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'};
|
|
||||||
}
|
|
||||||
|
|
||||||
$correspEmails = [];
|
|
||||||
if (isset($articleMeta->{'author-notes'})) {
|
|
||||||
$this->extractEmailsFromNode($articleMeta->{'author-notes'}, $correspEmails);
|
|
||||||
}
|
|
||||||
|
|
||||||
$affiliationMap = [];
|
|
||||||
if (isset($articleMeta->{'contrib-group'})) {
|
|
||||||
foreach ($articleMeta->{'contrib-group'}->children() as $child) {
|
|
||||||
if ($child->getName() === 'aff') {
|
|
||||||
$affId = (string) ($child['id'] ?? '');
|
|
||||||
$affText = $this->xmlNodeToString($child);
|
|
||||||
if ($affId) {
|
|
||||||
$affiliationMap[$affId] = $affText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (isset($front->{'article-meta'}->{'aff'})) {
|
|
||||||
foreach ($front->{'article-meta'}->{'aff'} as $aff) {
|
|
||||||
$affId = (string) ($aff['id'] ?? '');
|
|
||||||
$affText = $this->xmlNodeToString($aff);
|
|
||||||
if ($affId) {
|
|
||||||
$affiliationMap[$affId] = $affText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($articleMeta->{'contrib-group'})) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($articleMeta->{'contrib-group'}->contrib as $contrib) {
|
|
||||||
$contribType = (string) ($contrib['contrib-type'] ?? '');
|
|
||||||
if ($contribType !== 'author') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$nameNode = $contrib->name;
|
|
||||||
if (!$nameNode) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$surname = (string) ($nameNode->surname ?? '');
|
|
||||||
$givenNames = (string) ($nameNode->{'given-names'} ?? '');
|
|
||||||
$fullName = trim($givenNames . ' ' . $surname);
|
|
||||||
|
|
||||||
if (empty($fullName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$email = '';
|
|
||||||
if (isset($contrib->email)) {
|
|
||||||
$email = strtolower(trim((string) $contrib->email));
|
|
||||||
}
|
|
||||||
|
|
||||||
$affiliation = '';
|
|
||||||
if (isset($contrib->xref)) {
|
|
||||||
foreach ($contrib->xref as $xref) {
|
|
||||||
if ((string) $xref['ref-type'] === 'aff') {
|
|
||||||
$rid = (string) $xref['rid'];
|
|
||||||
if (isset($affiliationMap[$rid])) {
|
|
||||||
$affiliation = $affiliationMap[$rid];
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (empty($affiliation) && isset($contrib->aff)) {
|
|
||||||
$affiliation = $this->xmlNodeToString($contrib->aff);
|
|
||||||
}
|
|
||||||
|
|
||||||
$isCorresponding = false;
|
|
||||||
if (isset($contrib->xref)) {
|
|
||||||
foreach ($contrib->xref as $xref) {
|
|
||||||
if ((string) $xref['ref-type'] === 'corresp') {
|
|
||||||
$isCorresponding = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((string) ($contrib['corresp'] ?? '') === 'yes') {
|
|
||||||
$isCorresponding = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($email) && $isCorresponding && !empty($correspEmails)) {
|
|
||||||
$email = $correspEmails[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($email)) {
|
|
||||||
$extracted = $this->extractEmailFromText($affiliation);
|
|
||||||
if ($extracted) {
|
|
||||||
$email = $extracted;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($email)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$results[] = [
|
|
||||||
'name' => $fullName,
|
|
||||||
'email' => strtolower($email),
|
|
||||||
'affiliation' => $this->cleanAffiliation($affiliation),
|
|
||||||
'article_title' => $title,
|
|
||||||
'article_id' => $pmcId,
|
|
||||||
'journal' => $journal,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Pagination ====================
|
|
||||||
|
|
||||||
private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $page, $perPage, $source)
|
|
||||||
{
|
|
||||||
$totalPages = $totalArticles > 0 ? ceil($totalArticles / $perPage) : 0;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'experts' => $experts,
|
|
||||||
'total' => $expertCount,
|
|
||||||
'articles_scanned' => $articlesScanned,
|
|
||||||
'total_articles' => $totalArticles,
|
|
||||||
'page' => $page,
|
|
||||||
'per_page' => $perPage,
|
|
||||||
'total_pages' => $totalPages,
|
|
||||||
'has_more' => $page < $totalPages,
|
|
||||||
'source' => $source,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Aggregation ====================
|
|
||||||
|
|
||||||
private function aggregateExperts($authorRecords)
|
|
||||||
{
|
|
||||||
$map = [];
|
|
||||||
|
|
||||||
foreach ($authorRecords as $record) {
|
|
||||||
$key = strtolower(trim($record['email']));
|
|
||||||
if (empty($key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isset($map[$key])) {
|
|
||||||
$map[$key] = [
|
|
||||||
'name' => $record['name'],
|
|
||||||
'email' => $record['email'],
|
|
||||||
'affiliation' => $record['affiliation'],
|
|
||||||
'paper_count' => 0,
|
|
||||||
'papers' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$map[$key]['paper_count']++;
|
|
||||||
|
|
||||||
if (count($map[$key]['papers']) < 10) {
|
|
||||||
$map[$key]['papers'][] = [
|
|
||||||
'title' => $record['article_title'],
|
|
||||||
'article_id' => $record['article_id'],
|
|
||||||
'journal' => $record['journal'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (empty($map[$key]['affiliation']) && !empty($record['affiliation'])) {
|
|
||||||
$map[$key]['affiliation'] = $record['affiliation'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$experts = array_values($map);
|
|
||||||
|
|
||||||
usort($experts, function ($a, $b) {
|
|
||||||
return $b['paper_count'] - $a['paper_count'];
|
|
||||||
});
|
|
||||||
|
|
||||||
return $experts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Excel Export ====================
|
// ==================== Excel Export ====================
|
||||||
@@ -880,188 +444,4 @@ class ExpertFinder extends Base
|
|||||||
'count' => count($experts),
|
'count' => count($experts),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Database Storage ====================
|
|
||||||
|
|
||||||
private function saveExperts($experts, $field, $source)
|
|
||||||
{
|
|
||||||
$inserted = 0;
|
|
||||||
$existing = 0;
|
|
||||||
$fieldEnrich = 0;
|
|
||||||
|
|
||||||
foreach ($experts as $expert) {
|
|
||||||
$email = strtolower(trim($expert['email']));
|
|
||||||
if (empty($email)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$exists = Db::name('expert')->where('email', $email)->find();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
$existing++;
|
|
||||||
$fieldEnrich += $this->enrichExpertField($exists['expert_id'], $field);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$insert = [
|
|
||||||
'name' => mb_substr($expert['name'], 0, 255),
|
|
||||||
'email' => mb_substr($email, 0, 128),
|
|
||||||
'affiliation' => mb_substr($expert['affiliation'], 0, 128),
|
|
||||||
'source' => mb_substr($source, 0, 128),
|
|
||||||
'ctime' => time(),
|
|
||||||
'ltime' => 0,
|
|
||||||
'state' => 0,
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
$expertId = Db::name('expert')->insertGetId($insert);
|
|
||||||
$this->enrichExpertField($expertId, $field);
|
|
||||||
$inserted++;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
$existing++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['inserted' => $inserted, 'existing' => $existing, 'field_enriched' => $fieldEnrich];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Fetch Log (t_expert_fetch) ====================
|
|
||||||
|
|
||||||
private function getFetchLog($field, $source)
|
|
||||||
{
|
|
||||||
$log = Db::name('expert_fetch')
|
|
||||||
->where('field', $field)
|
|
||||||
->where('source', $source)
|
|
||||||
->find();
|
|
||||||
|
|
||||||
if (!$log) {
|
|
||||||
return ['last_page' => 0, 'total_pages' => 0, 'last_time' => 0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $log;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function updateFetchLog($field, $source, $lastPage, $totalPages)
|
|
||||||
{
|
|
||||||
$exists = Db::name('expert_fetch')
|
|
||||||
->where('field', $field)
|
|
||||||
->where('source', $source)
|
|
||||||
->find();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
Db::name('expert_fetch')
|
|
||||||
->where('expert_fetch_id', $exists['expert_fetch_id'])
|
|
||||||
->update([
|
|
||||||
'last_page' => $lastPage,
|
|
||||||
'total_pages' => $totalPages,
|
|
||||||
'last_time' => time(),
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Db::name('expert_fetch')->insert([
|
|
||||||
'field' => mb_substr($field, 0, 128),
|
|
||||||
'source' => mb_substr($source, 0, 128),
|
|
||||||
'last_page' => $lastPage,
|
|
||||||
'total_pages' => $totalPages,
|
|
||||||
'last_time' => time(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function enrichExpertField($expertId, $field)
|
|
||||||
{
|
|
||||||
$field = trim($field);
|
|
||||||
if (empty($field)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$exists = Db::name('expert_field')
|
|
||||||
->where('expert_id', $expertId)
|
|
||||||
->where('field', $field)
|
|
||||||
->where('state', 0)
|
|
||||||
->find();
|
|
||||||
|
|
||||||
if ($exists) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
Db::name('expert_field')->insert([
|
|
||||||
'expert_id' => $expertId,
|
|
||||||
'major_id' => 0,
|
|
||||||
'field' => mb_substr($field, 0, 128),
|
|
||||||
'state' => 0,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Helper Methods ====================
|
|
||||||
|
|
||||||
private function extractEmailFromText($text)
|
|
||||||
{
|
|
||||||
if (empty($text)) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/[Ee]lectronic address:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
|
||||||
return strtolower(trim($m[1], '.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/[Ee]-?mail:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
|
||||||
return strtolower(trim($m[1], '.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $m)) {
|
|
||||||
return strtolower(trim($m[1], '.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function extractEmailsFromNode($node, &$emails)
|
|
||||||
{
|
|
||||||
if ($node === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($node->children() as $child) {
|
|
||||||
if ($child->getName() === 'email') {
|
|
||||||
$email = strtolower(trim((string) $child));
|
|
||||||
if (!empty($email) && !in_array($email, $emails)) {
|
|
||||||
$emails[] = $email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->extractEmailsFromNode($child, $emails);
|
|
||||||
}
|
|
||||||
|
|
||||||
$text = (string) $node;
|
|
||||||
if (preg_match_all('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $matches)) {
|
|
||||||
foreach ($matches[1] as $email) {
|
|
||||||
$email = strtolower(trim($email, '.'));
|
|
||||||
if (!in_array($email, $emails)) {
|
|
||||||
$emails[] = $email;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function cleanAffiliation($text)
|
|
||||||
{
|
|
||||||
$text = preg_replace('/\s*[Ee]lectronic address:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
|
||||||
$text = preg_replace('/\s*[Ee]-?mail:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
|
||||||
$text = preg_replace('/\s*\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/', '', $text);
|
|
||||||
$text = trim($text, " \t\n\r\0\x0B.,;");
|
|
||||||
return $text;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function xmlNodeToString($node)
|
|
||||||
{
|
|
||||||
if ($node === null) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$xml = $node->asXML();
|
|
||||||
$text = strip_tags($xml);
|
|
||||||
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
|
||||||
return trim(preg_replace('/\s+/', ' ', $text));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
253
application/api/controller/MailTemplate.php
Normal file
253
application/api/controller/MailTemplate.php
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\controller;
|
||||||
|
|
||||||
|
use think\Db;
|
||||||
|
use think\Validate;
|
||||||
|
|
||||||
|
class MailTemplate extends Base
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create or update a mail template (V2)
|
||||||
|
* POST fields:
|
||||||
|
* - template_id (optional)
|
||||||
|
* - journal_id, scene, language, title, subject, body_html, body_text, variables_json, version, is_active
|
||||||
|
*/
|
||||||
|
public function saveTemplate()
|
||||||
|
{
|
||||||
|
$data = $this->request->post();
|
||||||
|
|
||||||
|
$rule = new Validate([
|
||||||
|
'journal_id' => 'require|number',
|
||||||
|
'scene' => 'require',
|
||||||
|
'language' => 'require',
|
||||||
|
'title' => 'require',
|
||||||
|
'subject' => 'require',
|
||||||
|
]);
|
||||||
|
if (!$rule->check($data)) {
|
||||||
|
return jsonError($rule->getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'journal_id' => intval($data['journal_id']),
|
||||||
|
'scene' => trim($data['scene']),
|
||||||
|
'language' => trim($data['language']),
|
||||||
|
'title' => trim($data['title']),
|
||||||
|
'subject' => trim($data['subject']),
|
||||||
|
'body_html' => $data['body_html'] ?? '',
|
||||||
|
'body_text' => $data['body_text'] ?? '',
|
||||||
|
'variables_json' => $data['variables_json'] ?? '',
|
||||||
|
'version' => intval($data['version'] ?? 1),
|
||||||
|
'is_active' => intval($data['is_active'] ?? 1),
|
||||||
|
'utime' => time(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$templateId = intval($data['template_id'] ?? 0);
|
||||||
|
if ($templateId) {
|
||||||
|
$exists = Db::name('mail_template')->where('template_id', $templateId)->where('state', 0)->find();
|
||||||
|
if (!$exists) {
|
||||||
|
return jsonError('Template not found');
|
||||||
|
}
|
||||||
|
Db::name('mail_template')->where('template_id', $templateId)->update($payload);
|
||||||
|
return jsonSuccess(['template_id' => $templateId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['ctime'] = time();
|
||||||
|
$payload['state'] = 0;
|
||||||
|
$newId = Db::name('mail_template')->insertGetId($payload);
|
||||||
|
return jsonSuccess(['template_id' => $newId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft delete a template
|
||||||
|
* Params: template_id
|
||||||
|
*/
|
||||||
|
public function deleteTemplate()
|
||||||
|
{
|
||||||
|
$templateId = intval($this->request->param('template_id', 0));
|
||||||
|
if (!$templateId) {
|
||||||
|
return jsonError('template_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('mail_template')->where('template_id', $templateId)->update(['state' => 1, 'utime' => time()]);
|
||||||
|
return jsonSuccess([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get template detail
|
||||||
|
*/
|
||||||
|
public function getTemplate()
|
||||||
|
{
|
||||||
|
$templateId = intval($this->request->param('template_id', 0));
|
||||||
|
if (!$templateId) {
|
||||||
|
return jsonError('template_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('state', 0)->find();
|
||||||
|
if (!$tpl) {
|
||||||
|
return jsonError('Template not found');
|
||||||
|
}
|
||||||
|
return jsonSuccess(['template' => $tpl]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List templates
|
||||||
|
* Params: journal_id, scene(optional), language(optional), is_active(optional)
|
||||||
|
*/
|
||||||
|
public function listTemplates()
|
||||||
|
{
|
||||||
|
$journalId = intval($this->request->param('journal_id', 0));
|
||||||
|
if (!$journalId) {
|
||||||
|
return jsonError('journal_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$scene = trim($this->request->param('scene', ''));
|
||||||
|
$language = trim($this->request->param('language', ''));
|
||||||
|
$isActive = $this->request->param('is_active', '');
|
||||||
|
|
||||||
|
$where = ['journal_id' => $journalId, 'state' => 0];
|
||||||
|
if ($scene !== '') $where['scene'] = $scene;
|
||||||
|
if ($language !== '') $where['language'] = $language;
|
||||||
|
if ($isActive !== '') $where['is_active'] = intval($isActive);
|
||||||
|
|
||||||
|
$list = Db::name('mail_template')
|
||||||
|
->where($where)
|
||||||
|
->order('is_active desc, utime desc, template_id desc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return jsonSuccess(['list' => $list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create or update a global mail style
|
||||||
|
* 当前 style 表字段:
|
||||||
|
* - style_id, name, description, header_html, footer_html, state, ctime
|
||||||
|
*/
|
||||||
|
public function saveStyle()
|
||||||
|
{
|
||||||
|
$data = $this->request->post();
|
||||||
|
$rule = new Validate([
|
||||||
|
'name' => 'require',
|
||||||
|
]);
|
||||||
|
if (!$rule->check($data)) {
|
||||||
|
return jsonError($rule->getError());
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'name' => trim($data['name']),
|
||||||
|
'description' => trim($data['description'] ?? ''),
|
||||||
|
'header_html' => $data['header_html'] ?? '',
|
||||||
|
'footer_html' => $data['footer_html'] ?? '',
|
||||||
|
'state' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
$styleId = intval($data['style_id'] ?? 0);
|
||||||
|
if ($styleId) {
|
||||||
|
$exists = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
|
||||||
|
if (!$exists) {
|
||||||
|
return jsonError('Style not found');
|
||||||
|
}
|
||||||
|
Db::name('mail_style')->where('style_id', $styleId)->update($payload);
|
||||||
|
return jsonSuccess(['style_id' => $styleId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$payload['ctime'] = time();
|
||||||
|
$newId = Db::name('mail_style')->insertGetId($payload);
|
||||||
|
return jsonSuccess(['style_id' => $newId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a global mail style (soft delete)
|
||||||
|
*/
|
||||||
|
public function deleteStyle()
|
||||||
|
{
|
||||||
|
$styleId = intval($this->request->param('style_id', 0));
|
||||||
|
if (!$styleId) {
|
||||||
|
return jsonError('style_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
Db::name('mail_style')->where('style_id', $styleId)->update(['state' => 1]);
|
||||||
|
return jsonSuccess([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get style detail
|
||||||
|
*/
|
||||||
|
public function getStyle()
|
||||||
|
{
|
||||||
|
$styleId = intval($this->request->param('style_id', 0));
|
||||||
|
if (!$styleId) {
|
||||||
|
return jsonError('style_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$style = Db::name('mail_style')->where('style_id', $styleId)->where('state', 0)->find();
|
||||||
|
if (!$style) {
|
||||||
|
return jsonError('Style not found');
|
||||||
|
}
|
||||||
|
return jsonSuccess(['style' => $style]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List styles
|
||||||
|
* 现在样式不再按 scene / language 区分,只返回全部正常状态的样式
|
||||||
|
*/
|
||||||
|
public function listStyles()
|
||||||
|
{
|
||||||
|
$where = ['state' => 0];
|
||||||
|
|
||||||
|
$list = Db::name('mail_style')
|
||||||
|
->where($where)
|
||||||
|
->order('style_id desc')
|
||||||
|
->select();
|
||||||
|
|
||||||
|
return jsonSuccess(['list' => $list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render preview for a template预览效果,没啥用
|
||||||
|
* Params: template_id, vars (json string)
|
||||||
|
*/
|
||||||
|
public function preview()
|
||||||
|
{
|
||||||
|
$templateId = intval($this->request->param('template_id', 0));
|
||||||
|
$varsJson = $this->request->param('vars', '');
|
||||||
|
if (!$templateId) {
|
||||||
|
return jsonError('template_id is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
$tpl = Db::name('mail_template')->where('template_id', $templateId)->where('state', 0)->find();
|
||||||
|
if (!$tpl) {
|
||||||
|
return jsonError('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
$vars = [];
|
||||||
|
if ($varsJson) {
|
||||||
|
$decoded = json_decode($varsJson, true);
|
||||||
|
if (is_array($decoded)) $vars = $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
$subject = $this->render($tpl['subject'], $vars);
|
||||||
|
$body = $this->render($tpl['body_html'], $vars);
|
||||||
|
|
||||||
|
// For preview we do not enforce a style; caller can combine with style if needed
|
||||||
|
return jsonSuccess(['subject' => $subject, 'body' => $body]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function render($tpl, $vars)
|
||||||
|
{
|
||||||
|
if (!is_string($tpl) || empty($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;
|
||||||
|
// backward compatible placeholders
|
||||||
|
$replace['{' . $key . '}'] = (string)$v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return str_replace(array_keys($replace), array_values($replace), $tpl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,24 +3,48 @@
|
|||||||
namespace app\api\job;
|
namespace app\api\job;
|
||||||
|
|
||||||
use think\queue\Job;
|
use think\queue\Job;
|
||||||
use think\Log;
|
use app\common\ExpertFinderService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 专家抓取队列任务
|
||||||
|
* 注意:此任务推送到队列名 "FetchExperts",必须单独启动 worker 才会执行:
|
||||||
|
* php think queue:listen --queue FetchExperts
|
||||||
|
* 若只运行 queue:listen 不指定队列,默认只消费 "mail",本任务不会被执行。
|
||||||
|
*/
|
||||||
class FetchExperts
|
class FetchExperts
|
||||||
{
|
{
|
||||||
public function fire(Job $job, $data)
|
public function fire(Job $job, $data)
|
||||||
{
|
{
|
||||||
try {
|
$field = isset($data['field']) ? $data['field'] : '';
|
||||||
$finder = new \app\api\controller\ExpertFinder();
|
// $attempts = $job->attempts();
|
||||||
$result = $finder->doFetchForField(
|
//
|
||||||
$data['field'],
|
$service = new ExpertFinderService();
|
||||||
$data['source'] ?? 'pubmed',
|
// $service->log('[FetchExperts] start field=' . $field . ' attempts=' . $attempts);
|
||||||
$data['per_page'] ?? 100,
|
//
|
||||||
$data['min_year'] ?? null
|
// try {
|
||||||
|
$result = $service->doFetchForField(
|
||||||
|
$field,
|
||||||
|
isset($data['source']) ? $data['source'] : 'pubmed',
|
||||||
|
isset($data['per_page']) ? intval($data['per_page']) : 100,
|
||||||
|
isset($data['min_year']) ? $data['min_year'] : null
|
||||||
);
|
);
|
||||||
Log::info('FetchExperts completed: ' . json_encode($result));
|
// $service->log('[FetchExperts] completed field=' . $field . ' result=' . json_encode($result));
|
||||||
} catch (\Exception $e) {
|
// } catch (\Throwable $e) {
|
||||||
Log::error('FetchExperts failed: ' . $e->getMessage());
|
// $service->log(
|
||||||
}
|
// '[FetchExperts] failed field=' . $field .
|
||||||
|
// ' msg=' . $e->getMessage() .
|
||||||
|
// ' file=' . $e->getFile() .
|
||||||
|
// ' line=' . $e->getLine()
|
||||||
|
// );
|
||||||
|
//
|
||||||
|
// if ($attempts >= 3) {
|
||||||
|
// $job->delete();
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// $job->release(60);
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
$job->delete();
|
$job->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
35
application/api/job/PromotionSend.php
Normal file
35
application/api/job/PromotionSend.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\api\job;
|
||||||
|
|
||||||
|
use think\queue\Job;
|
||||||
|
use app\common\PromotionService;
|
||||||
|
|
||||||
|
class PromotionSend
|
||||||
|
{
|
||||||
|
public function fire(Job $job, $data)
|
||||||
|
{
|
||||||
|
$taskId = intval(isset($data['task_id']) ? $data['task_id'] : 0);
|
||||||
|
$service = new PromotionService();
|
||||||
|
|
||||||
|
if (!$taskId) {
|
||||||
|
$service->log('[PromotionSend] missing task_id, job deleted');
|
||||||
|
$job->delete();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $service->processNextEmail($taskId);
|
||||||
|
$service->log('[PromotionSend] task=' . $taskId . ' result=' . json_encode($result));
|
||||||
|
|
||||||
|
if (!empty($result['done'])) {
|
||||||
|
$reason = isset($result['reason']) ? $result['reason'] : '';
|
||||||
|
$service->log('[PromotionSend] task=' . $taskId . ' finished, reason=' . $reason);
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$service->log('[PromotionSend] task=' . $taskId . ' exception=' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
592
application/common/ExpertFinderService.php
Normal file
592
application/common/ExpertFinderService.php
Normal file
@@ -0,0 +1,592 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace app\common;
|
||||||
|
|
||||||
|
use think\Db;
|
||||||
|
use GuzzleHttp\Client;
|
||||||
|
|
||||||
|
class ExpertFinderService
|
||||||
|
{
|
||||||
|
private $httpClient;
|
||||||
|
private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/';
|
||||||
|
private $logFile;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->httpClient = new Client([
|
||||||
|
'timeout' => 180,
|
||||||
|
'connect_timeout' => 15,
|
||||||
|
'verify' => false,
|
||||||
|
]);
|
||||||
|
$this->logFile = ROOT_PATH . 'runtime' . DS . 'expert_finder.log';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function doFetchForField($field, $source = 'pubmed', $perPage = 100, $minYear = null)
|
||||||
|
{
|
||||||
|
if ($minYear === null) {
|
||||||
|
$minYear = date('Y') - 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetchLog = $this->getFetchLog($field, $source);
|
||||||
|
$page = $fetchLog['last_page'] + 1;
|
||||||
|
|
||||||
|
if ($source === 'pmc') {
|
||||||
|
$result = $this->searchViaPMC($field, $perPage, $minYear, $page);
|
||||||
|
} else {
|
||||||
|
$result = $this->searchViaPubMed($field, $perPage, $minYear, $page);
|
||||||
|
}
|
||||||
|
|
||||||
|
$saveResult = $this->saveExperts($result['experts'], $field, $source);
|
||||||
|
|
||||||
|
$nextPage = $result['has_more'] ? $page : 0;
|
||||||
|
$totalPages = isset($result['total_pages']) ? $result['total_pages'] : 0;
|
||||||
|
$this->updateFetchLog($field, $source, $nextPage, $totalPages);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'keyword' => $field,
|
||||||
|
'page' => $page,
|
||||||
|
'experts_found' => $result['total'],
|
||||||
|
'saved_new' => $saveResult['inserted'],
|
||||||
|
'saved_exist' => $saveResult['existing'],
|
||||||
|
'field_enriched' => $saveResult['field_enriched'],
|
||||||
|
'has_more' => $result['has_more'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function searchExperts($keyword, $perPage, $minYear, $page, $source)
|
||||||
|
{
|
||||||
|
if ($source === 'pmc') {
|
||||||
|
return $this->searchViaPMC($keyword, $perPage, $minYear, $page);
|
||||||
|
}
|
||||||
|
return $this->searchViaPubMed($keyword, $perPage, $minYear, $page);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveExperts($experts, $field, $source)
|
||||||
|
{
|
||||||
|
$inserted = 0;
|
||||||
|
$existing = 0;
|
||||||
|
$fieldEnrich = 0;
|
||||||
|
|
||||||
|
foreach ($experts as $expert) {
|
||||||
|
$email = strtolower(trim($expert['email']));
|
||||||
|
if (empty($email)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exists = Db::name('expert')->where('email', $email)->find();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
$existing++;
|
||||||
|
$fieldEnrich += $this->enrichExpertField($exists['expert_id'], $field);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$insert = [
|
||||||
|
'name' => mb_substr($expert['name'], 0, 255),
|
||||||
|
'email' => mb_substr($email, 0, 128),
|
||||||
|
'affiliation' => mb_substr($expert['affiliation'], 0, 128),
|
||||||
|
'source' => mb_substr($source, 0, 128),
|
||||||
|
'ctime' => time(),
|
||||||
|
'ltime' => 0,
|
||||||
|
'state' => 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$expertId = Db::name('expert')->insertGetId($insert);
|
||||||
|
$this->enrichExpertField($expertId, $field);
|
||||||
|
$inserted++;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$existing++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['inserted' => $inserted, 'existing' => $existing, 'field_enriched' => $fieldEnrich];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFetchLog($field, $source)
|
||||||
|
{
|
||||||
|
$log = Db::name('expert_fetch')
|
||||||
|
->where('field', $field)
|
||||||
|
->where('source', $source)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if (!$log) {
|
||||||
|
return ['last_page' => 0, 'total_pages' => 0, 'last_time' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateFetchLog($field, $source, $lastPage, $totalPages)
|
||||||
|
{
|
||||||
|
$exists = Db::name('expert_fetch')
|
||||||
|
->where('field', $field)
|
||||||
|
->where('source', $source)
|
||||||
|
->find();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
Db::name('expert_fetch')
|
||||||
|
->where('expert_fetch_id', $exists['expert_fetch_id'])
|
||||||
|
->update([
|
||||||
|
'last_page' => $lastPage,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'last_time' => time(),
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
Db::name('expert_fetch')->insert([
|
||||||
|
'field' => mb_substr($field, 0, 128),
|
||||||
|
'source' => mb_substr($source, 0, 128),
|
||||||
|
'last_page' => $lastPage,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'last_time' => time(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PubMed Search ====================
|
||||||
|
|
||||||
|
private function searchViaPubMed($keyword, $perPage, $minYear, $page = 1)
|
||||||
|
{
|
||||||
|
set_time_limit(600);
|
||||||
|
|
||||||
|
$searchResult = $this->esearch('pubmed', $keyword, $perPage, $minYear, $page);
|
||||||
|
$ids = $searchResult['ids'];
|
||||||
|
$totalArticles = $searchResult['total'];
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pubmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allAuthors = [];
|
||||||
|
$batches = array_chunk($ids, 50);
|
||||||
|
foreach ($batches as $batch) {
|
||||||
|
$xml = $this->efetchWithRetry('pubmed', $batch);
|
||||||
|
if ($xml) {
|
||||||
|
$authors = $this->parsePubMedXml($xml);
|
||||||
|
$allAuthors = array_merge($allAuthors, $authors);
|
||||||
|
}
|
||||||
|
usleep(400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$experts = $this->aggregateExperts($allAuthors);
|
||||||
|
|
||||||
|
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pubmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PMC Search ====================
|
||||||
|
|
||||||
|
private function searchViaPMC($keyword, $perPage, $minYear, $page = 1)
|
||||||
|
{
|
||||||
|
set_time_limit(600);
|
||||||
|
|
||||||
|
$searchResult = $this->esearch('pmc', $keyword, $perPage, $minYear, $page);
|
||||||
|
$ids = $searchResult['ids'];
|
||||||
|
$totalArticles = $searchResult['total'];
|
||||||
|
|
||||||
|
if (empty($ids)) {
|
||||||
|
return $this->buildPagedResult([], 0, 0, $totalArticles, $page, $perPage, 'pmc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$allAuthors = [];
|
||||||
|
$batches = array_chunk($ids, 5);
|
||||||
|
foreach ($batches as $batch) {
|
||||||
|
$xml = $this->efetchWithRetry('pmc', $batch);
|
||||||
|
if ($xml) {
|
||||||
|
$authors = $this->parsePMCXml($xml);
|
||||||
|
$allAuthors = array_merge($allAuthors, $authors);
|
||||||
|
}
|
||||||
|
usleep(500000);
|
||||||
|
}
|
||||||
|
|
||||||
|
$experts = $this->aggregateExperts($allAuthors);
|
||||||
|
|
||||||
|
return $this->buildPagedResult($experts, count($experts), count($ids), $totalArticles, $page, $perPage, 'pmc');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== NCBI API ====================
|
||||||
|
|
||||||
|
private function esearch($db, $keyword, $perPage, $minYear, $page = 1)
|
||||||
|
{
|
||||||
|
$term = $keyword . ' AND ' . $minYear . ':' . date('Y') . '[pdat]';
|
||||||
|
$retstart = ($page - 1) * $perPage;
|
||||||
|
|
||||||
|
$response = $this->httpClient->get($this->ncbiBaseUrl . 'esearch.fcgi', [
|
||||||
|
'query' => [
|
||||||
|
'db' => $db,
|
||||||
|
'term' => $term,
|
||||||
|
'retstart' => $retstart,
|
||||||
|
'retmax' => $perPage,
|
||||||
|
'retmode' => 'json',
|
||||||
|
'sort' => 'relevance',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$data = json_decode($response->getBody()->getContents(), true);
|
||||||
|
$ids = $data['esearchresult']['idlist'] ?? [];
|
||||||
|
$total = intval($data['esearchresult']['count'] ?? 0);
|
||||||
|
|
||||||
|
return ['ids' => $ids, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function efetch($db, $ids)
|
||||||
|
{
|
||||||
|
$response = $this->httpClient->post($this->ncbiBaseUrl . 'efetch.fcgi', [
|
||||||
|
'form_params' => [
|
||||||
|
'db' => $db,
|
||||||
|
'id' => implode(',', $ids),
|
||||||
|
'retmode' => 'xml',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response->getBody()->getContents();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function efetchWithRetry($db, $ids, $maxRetries = 3)
|
||||||
|
{
|
||||||
|
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
|
||||||
|
try {
|
||||||
|
return $this->efetch($db, $ids);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
if ($attempt === $maxRetries) {
|
||||||
|
if (count($ids) > 1) {
|
||||||
|
$half = ceil(count($ids) / 2);
|
||||||
|
$firstHalf = array_slice($ids, 0, $half);
|
||||||
|
$secondHalf = array_slice($ids, $half);
|
||||||
|
$xml1 = $this->efetchWithRetry($db, $firstHalf, 2);
|
||||||
|
$xml2 = $this->efetchWithRetry($db, $secondHalf, 2);
|
||||||
|
return $this->mergeXml($xml1, $xml2);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
sleep($attempt * 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mergeXml($xml1, $xml2)
|
||||||
|
{
|
||||||
|
if (empty($xml1)) return $xml2;
|
||||||
|
if (empty($xml2)) return $xml1;
|
||||||
|
return $xml1 . "\n" . $xml2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PubMed XML Parsing ====================
|
||||||
|
|
||||||
|
private function parsePubMedXml($xmlString)
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$xml = simplexml_load_string($xmlString);
|
||||||
|
if ($xml === false) {
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($xml->PubmedArticle as $article) {
|
||||||
|
$citation = $article->MedlineCitation;
|
||||||
|
$articleData = $citation->Article;
|
||||||
|
$title = $this->xmlNodeToString($articleData->ArticleTitle);
|
||||||
|
$pmid = (string) $citation->PMID;
|
||||||
|
|
||||||
|
$journal = '';
|
||||||
|
if (isset($articleData->Journal->Title)) {
|
||||||
|
$journal = (string) $articleData->Journal->Title;
|
||||||
|
}
|
||||||
|
if (!isset($articleData->AuthorList->Author)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($articleData->AuthorList->Author as $author) {
|
||||||
|
$lastName = (string) ($author->LastName ?? '');
|
||||||
|
$foreName = (string) ($author->ForeName ?? '');
|
||||||
|
$fullName = trim($foreName . ' ' . $lastName);
|
||||||
|
if (empty($fullName)) continue;
|
||||||
|
|
||||||
|
$email = '';
|
||||||
|
$affiliation = '';
|
||||||
|
if (isset($author->AffiliationInfo)) {
|
||||||
|
foreach ($author->AffiliationInfo as $affInfo) {
|
||||||
|
$affText = (string) $affInfo->Affiliation;
|
||||||
|
if (empty($affiliation)) $affiliation = $affText;
|
||||||
|
if (empty($email)) $email = $this->extractEmailFromText($affText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($email)) continue;
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'name' => $fullName,
|
||||||
|
'email' => strtolower($email),
|
||||||
|
'affiliation' => $this->cleanAffiliation($affiliation),
|
||||||
|
'article_title' => $title,
|
||||||
|
'article_id' => $pmid,
|
||||||
|
'journal' => $journal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== PMC XML Parsing ====================
|
||||||
|
|
||||||
|
private function parsePMCXml($xmlString)
|
||||||
|
{
|
||||||
|
$results = [];
|
||||||
|
libxml_use_internal_errors(true);
|
||||||
|
$xml = simplexml_load_string($xmlString);
|
||||||
|
if ($xml === false) {
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
$articles = $xml->article ?? $xml->children();
|
||||||
|
|
||||||
|
foreach ($articles as $article) {
|
||||||
|
if ($article->getName() !== 'article') continue;
|
||||||
|
|
||||||
|
$front = $article->front;
|
||||||
|
if (!$front) continue;
|
||||||
|
$articleMeta = $front->{'article-meta'};
|
||||||
|
if (!$articleMeta) continue;
|
||||||
|
|
||||||
|
$title = $this->xmlNodeToString($articleMeta->{'title-group'}->{'article-title'} ?? null);
|
||||||
|
$pmcId = '';
|
||||||
|
if (isset($articleMeta->{'article-id'})) {
|
||||||
|
foreach ($articleMeta->{'article-id'} as $idNode) {
|
||||||
|
if ((string) $idNode['pub-id-type'] === 'pmc') {
|
||||||
|
$pmcId = (string) $idNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$journal = '';
|
||||||
|
if (isset($front->{'journal-meta'}->{'journal-title'})) {
|
||||||
|
$journal = (string) $front->{'journal-meta'}->{'journal-title'};
|
||||||
|
} elseif (isset($front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'})) {
|
||||||
|
$journal = (string) $front->{'journal-meta'}->{'journal-title-group'}->{'journal-title'};
|
||||||
|
}
|
||||||
|
|
||||||
|
$correspEmails = [];
|
||||||
|
if (isset($articleMeta->{'author-notes'})) {
|
||||||
|
$this->extractEmailsFromNode($articleMeta->{'author-notes'}, $correspEmails);
|
||||||
|
}
|
||||||
|
|
||||||
|
$affiliationMap = [];
|
||||||
|
if (isset($articleMeta->{'contrib-group'})) {
|
||||||
|
foreach ($articleMeta->{'contrib-group'}->children() as $child) {
|
||||||
|
if ($child->getName() === 'aff') {
|
||||||
|
$affId = (string) ($child['id'] ?? '');
|
||||||
|
$affText = $this->xmlNodeToString($child);
|
||||||
|
if ($affId) $affiliationMap[$affId] = $affText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (isset($front->{'article-meta'}->{'aff'})) {
|
||||||
|
foreach ($front->{'article-meta'}->{'aff'} as $aff) {
|
||||||
|
$affId = (string) ($aff['id'] ?? '');
|
||||||
|
$affText = $this->xmlNodeToString($aff);
|
||||||
|
if ($affId) $affiliationMap[$affId] = $affText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($articleMeta->{'contrib-group'})) continue;
|
||||||
|
|
||||||
|
foreach ($articleMeta->{'contrib-group'}->contrib as $contrib) {
|
||||||
|
if ((string) ($contrib['contrib-type'] ?? '') !== 'author') continue;
|
||||||
|
$nameNode = $contrib->name;
|
||||||
|
if (!$nameNode) continue;
|
||||||
|
|
||||||
|
$surname = (string) ($nameNode->surname ?? '');
|
||||||
|
$givenNames = (string) ($nameNode->{'given-names'} ?? '');
|
||||||
|
$fullName = trim($givenNames . ' ' . $surname);
|
||||||
|
if (empty($fullName)) continue;
|
||||||
|
|
||||||
|
$email = '';
|
||||||
|
if (isset($contrib->email)) {
|
||||||
|
$email = strtolower(trim((string) $contrib->email));
|
||||||
|
}
|
||||||
|
|
||||||
|
$affiliation = '';
|
||||||
|
if (isset($contrib->xref)) {
|
||||||
|
foreach ($contrib->xref as $xref) {
|
||||||
|
if ((string) $xref['ref-type'] === 'aff') {
|
||||||
|
$rid = (string) $xref['rid'];
|
||||||
|
if (isset($affiliationMap[$rid])) {
|
||||||
|
$affiliation = $affiliationMap[$rid];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (empty($affiliation) && isset($contrib->aff)) {
|
||||||
|
$affiliation = $this->xmlNodeToString($contrib->aff);
|
||||||
|
}
|
||||||
|
|
||||||
|
$isCorresponding = false;
|
||||||
|
if (isset($contrib->xref)) {
|
||||||
|
foreach ($contrib->xref as $xref) {
|
||||||
|
if ((string) $xref['ref-type'] === 'corresp') $isCorresponding = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((string) ($contrib['corresp'] ?? '') === 'yes') $isCorresponding = true;
|
||||||
|
|
||||||
|
if (empty($email) && $isCorresponding && !empty($correspEmails)) {
|
||||||
|
$email = $correspEmails[0];
|
||||||
|
}
|
||||||
|
if (empty($email)) {
|
||||||
|
$extracted = $this->extractEmailFromText($affiliation);
|
||||||
|
if ($extracted) $email = $extracted;
|
||||||
|
}
|
||||||
|
if (empty($email)) continue;
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'name' => $fullName,
|
||||||
|
'email' => strtolower($email),
|
||||||
|
'affiliation' => $this->cleanAffiliation($affiliation),
|
||||||
|
'article_title' => $title,
|
||||||
|
'article_id' => $pmcId,
|
||||||
|
'journal' => $journal,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Aggregation / Pagination ====================
|
||||||
|
|
||||||
|
private function aggregateExperts($authorRecords)
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($authorRecords as $record) {
|
||||||
|
$key = strtolower(trim($record['email']));
|
||||||
|
if (empty($key)) continue;
|
||||||
|
|
||||||
|
if (!isset($map[$key])) {
|
||||||
|
$map[$key] = [
|
||||||
|
'name' => $record['name'],
|
||||||
|
'email' => $record['email'],
|
||||||
|
'affiliation' => $record['affiliation'],
|
||||||
|
'paper_count' => 0,
|
||||||
|
'papers' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$map[$key]['paper_count']++;
|
||||||
|
if (count($map[$key]['papers']) < 10) {
|
||||||
|
$map[$key]['papers'][] = [
|
||||||
|
'title' => $record['article_title'],
|
||||||
|
'article_id' => $record['article_id'],
|
||||||
|
'journal' => $record['journal'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (empty($map[$key]['affiliation']) && !empty($record['affiliation'])) {
|
||||||
|
$map[$key]['affiliation'] = $record['affiliation'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$experts = array_values($map);
|
||||||
|
usort($experts, function ($a, $b) {
|
||||||
|
return $b['paper_count'] - $a['paper_count'];
|
||||||
|
});
|
||||||
|
return $experts;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildPagedResult($experts, $expertCount, $articlesScanned, $totalArticles, $page, $perPage, $source)
|
||||||
|
{
|
||||||
|
$totalPages = $totalArticles > 0 ? ceil($totalArticles / $perPage) : 0;
|
||||||
|
return [
|
||||||
|
'experts' => $experts,
|
||||||
|
'total' => $expertCount,
|
||||||
|
'articles_scanned' => $articlesScanned,
|
||||||
|
'total_articles' => $totalArticles,
|
||||||
|
'page' => $page,
|
||||||
|
'per_page' => $perPage,
|
||||||
|
'total_pages' => $totalPages,
|
||||||
|
'has_more' => $page < $totalPages,
|
||||||
|
'source' => $source,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== DB Helpers ====================
|
||||||
|
|
||||||
|
private function enrichExpertField($expertId, $field)
|
||||||
|
{
|
||||||
|
$field = trim($field);
|
||||||
|
if (empty($field)) return 0;
|
||||||
|
|
||||||
|
$exists = Db::name('expert_field')
|
||||||
|
->where('expert_id', $expertId)
|
||||||
|
->where('field', $field)
|
||||||
|
->where('state', 0)
|
||||||
|
->find();
|
||||||
|
if ($exists) return 0;
|
||||||
|
|
||||||
|
Db::name('expert_field')->insert([
|
||||||
|
'expert_id' => $expertId,
|
||||||
|
'major_id' => 0,
|
||||||
|
'field' => mb_substr($field, 0, 128),
|
||||||
|
'state' => 0,
|
||||||
|
]);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Text Helpers ====================
|
||||||
|
|
||||||
|
private function extractEmailFromText($text)
|
||||||
|
{
|
||||||
|
if (empty($text)) return '';
|
||||||
|
if (preg_match('/[Ee]lectronic address:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
||||||
|
return strtolower(trim($m[1], '.'));
|
||||||
|
}
|
||||||
|
if (preg_match('/[Ee]-?mail:\s*([^\s;,]+@[^\s;,]+)/', $text, $m)) {
|
||||||
|
return strtolower(trim($m[1], '.'));
|
||||||
|
}
|
||||||
|
if (preg_match('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $m)) {
|
||||||
|
return strtolower(trim($m[1], '.'));
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractEmailsFromNode($node, &$emails)
|
||||||
|
{
|
||||||
|
if ($node === null) return;
|
||||||
|
foreach ($node->children() as $child) {
|
||||||
|
if ($child->getName() === 'email') {
|
||||||
|
$email = strtolower(trim((string) $child));
|
||||||
|
if (!empty($email) && !in_array($email, $emails)) $emails[] = $email;
|
||||||
|
}
|
||||||
|
$this->extractEmailsFromNode($child, $emails);
|
||||||
|
}
|
||||||
|
$text = (string) $node;
|
||||||
|
if (preg_match_all('/\b([a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})\b/', $text, $matches)) {
|
||||||
|
foreach ($matches[1] as $email) {
|
||||||
|
$email = strtolower(trim($email, '.'));
|
||||||
|
if (!in_array($email, $emails)) $emails[] = $email;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanAffiliation($text)
|
||||||
|
{
|
||||||
|
$text = preg_replace('/\s*[Ee]lectronic address:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
||||||
|
$text = preg_replace('/\s*[Ee]-?mail:\s*[^\s;,]+@[^\s;,]+/', '', $text);
|
||||||
|
$text = preg_replace('/\s*\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/', '', $text);
|
||||||
|
return trim($text, " \t\n\r\0\x0B.,;");
|
||||||
|
}
|
||||||
|
|
||||||
|
private function xmlNodeToString($node)
|
||||||
|
{
|
||||||
|
if ($node === null) return '';
|
||||||
|
$xml = $node->asXML();
|
||||||
|
$text = strip_tags($xml);
|
||||||
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_XML1, 'UTF-8');
|
||||||
|
return trim(preg_replace('/\s+/', ' ', $text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Logging ====================
|
||||||
|
|
||||||
|
public function log($msg)
|
||||||
|
{
|
||||||
|
$line = date('Y-m-d H:i:s') . ' ' . $msg . PHP_EOL;
|
||||||
|
@file_put_contents($this->logFile, $line, FILE_APPEND);
|
||||||
|
}
|
||||||
|
}
|
||||||
324
application/common/PromotionService.php
Normal file
324
application/common/PromotionService.php
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -172,8 +172,11 @@ return [
|
|||||||
// 日志保存目录
|
// 日志保存目录
|
||||||
'path' => LOG_PATH,
|
'path' => LOG_PATH,
|
||||||
// 日志记录级别
|
// 日志记录级别
|
||||||
'level' => ['log'],
|
'level' => ['error', 'warning', 'info', 'notice', 'debug'],
|
||||||
"max_files"=>3,
|
'file_size' => 1024 * 1024 * 2, // 2MB
|
||||||
|
'max_files'=>30,
|
||||||
|
'apart_day' => true,
|
||||||
|
'format' => '[%s][%s] %s',
|
||||||
],
|
],
|
||||||
|
|
||||||
// +----------------------------------------------------------------------
|
// +----------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user