From 65ba338a7dca4f5af87cd53b026a8db43d6c39c8 Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Fri, 13 Mar 2026 17:19:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8E=A8=E5=B9=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/EmailClient.php | 539 ++++++++++++- application/api/controller/ExpertFinder.php | 806 +++----------------- application/api/controller/MailTemplate.php | 253 ++++++ application/api/job/FetchExperts.php | 48 +- application/api/job/PromotionSend.php | 35 + application/common/ExpertFinderService.php | 592 ++++++++++++++ application/common/PromotionService.php | 324 ++++++++ application/config.php | 7 +- 8 files changed, 1863 insertions(+), 741 deletions(-) create mode 100644 application/api/controller/MailTemplate.php create mode 100644 application/api/job/PromotionSend.php create mode 100644 application/common/ExpertFinderService.php create mode 100644 application/common/PromotionService.php diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index 01712a5..c0ceccd 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -4,8 +4,11 @@ namespace app\api\controller; use think\Db; use think\Env; -use PHPMailer\PHPMailer; +use think\Cache; +use think\Queue; +use PHPMailer\PHPMailer\PHPMailer; use think\Validate; +use app\common\PromotionService; class EmailClient extends Base { @@ -173,7 +176,10 @@ class EmailClient extends Base /** * 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() { @@ -181,10 +187,26 @@ class EmailClient extends Base $toEmail = trim($this->request->param('to_email', '')); $subject = trim($this->request->param('subject', '')); $content = $this->request->param('content', ''); + $templateId = intval($this->request->param('template_id', 0)); + $varsJson = $this->request->param('vars_json', ''); + $styleId = intval($this->request->param('style_id', 0)); $accountId = intval($this->request->param('j_email_id', 0)); - if (!$journalId || empty($toEmail) || empty($subject) || empty($content)) { - return jsonError('journal_id, to_email, subject, content are required'); + if (!$journalId || empty($toEmail)) { + return jsonError('journal_id and to_email are required'); + } + + if ($templateId) { + $rendered = $this->renderFromTemplate($templateId, $journalId, $varsJson, $styleId); + if ($rendered['code'] !== 0) { + return jsonError($rendered['msg']); + } + $subject = $rendered['data']['subject']; + $content = $rendered['data']['body']; + } else { + if (empty($subject) || empty($content)) { + return jsonError('subject and content are required when template_id is not provided'); + } } if ($accountId) { @@ -227,18 +249,27 @@ class EmailClient extends Base /** * Send batch emails to multiple experts - * Params: journal_id, expert_ids (comma separated), subject, content - * Content supports variables: {name}, {email}, {affiliation} + * Params: journal_id, expert_ids (comma separated), + * - either subject + content + * - or template_id (recommended), with optional style_id + * + * Supported variables in subject/content or template: + * {name}/{email}/{affiliation}/{field} and {{name}}/{{email}}/... (both styles) */ public function sendBatch() { $journalId = intval($this->request->param('journal_id', 0)); $expertIds = trim($this->request->param('expert_ids', '')); - $subject = trim($this->request->param('subject', '')); - $content = $this->request->param('content', ''); + $subject = trim($this->request->param('subject', '')); + $content = $this->request->param('content', ''); + $templateId = intval($this->request->param('template_id', 0)); + $styleId = intval($this->request->param('style_id', 0)); - if (!$journalId || empty($expertIds) || empty($subject) || empty($content)) { - return jsonError('journal_id, expert_ids, subject, content are required'); + if (!$journalId || empty($expertIds)) { + return jsonError('journal_id and expert_ids are required'); + } + if (!$templateId && (empty($subject) || empty($content))) { + return jsonError('subject and content are required when template_id is not provided'); } $ids = array_map('intval', explode(',', $expertIds)); @@ -253,6 +284,9 @@ class EmailClient extends Base $skipped = 0; $errors = []; + $journal = Db::name('journal')->where('journal_id', $journalId)->find(); + $journalVars = $this->buildJournalVars($journal); + foreach ($experts as $expert) { $account = $this->pickSmtpAccount($journalId); if (!$account) { @@ -261,14 +295,28 @@ class EmailClient extends Base break; } - $personalContent = $this->replaceVariables($content, $expert); - $personalSubject = $this->replaceVariables($subject, $expert); + $expertVars = $this->buildExpertVars($expert); + $vars = array_merge($journalVars, $expertVars); + + if ($templateId) { + $rendered = $this->renderFromTemplate($templateId, $journalId, json_encode($vars, JSON_UNESCAPED_UNICODE), $styleId); + if ($rendered['code'] !== 0) { + $failed++; + $errors[] = $expert['email'] . ': ' . $rendered['msg']; + continue; + } + $personalSubject = $rendered['data']['subject']; + $personalContent = $rendered['data']['body']; + } else { + $personalContent = $this->renderVars($content, $vars); + $personalSubject = $this->renderVars($subject, $vars); + } $result = $this->doSendEmail($account, $expert['email'], $personalSubject, $personalContent); if ($result['status'] === 1) { Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent'); - Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1]); + Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, 'ltime' => time()]); $sent++; } else { $failed++; @@ -1023,6 +1071,404 @@ class EmailClient extends Base 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 ==================== /** @@ -1079,7 +1525,7 @@ class EmailClient extends Base private function doSendEmail($account, $toEmail, $subject, $htmlContent) { try { - $mail = new PHPMailer\PHPMailer(true); + $mail = new PHPMailer(true); $mail->isSMTP(); $mail->SMTPDebug = 0; $mail->CharSet = 'UTF-8'; @@ -1130,4 +1576,69 @@ class EmailClient extends Base 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]]; + } } diff --git a/application/api/controller/ExpertFinder.php b/application/api/controller/ExpertFinder.php index 9c1d2d2..e555faf 100644 --- a/application/api/controller/ExpertFinder.php +++ b/application/api/controller/ExpertFinder.php @@ -4,31 +4,21 @@ namespace app\api\controller; use think\Cache; use think\Db; -use GuzzleHttp\Client; +use think\Validate; +use app\common\ExpertFinderService; class ExpertFinder extends Base { - private $httpClient; - private $ncbiBaseUrl = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils/'; + private $service; public function __construct(\think\Request $request = null) { parent::__construct($request); - $this->httpClient = new Client([ - 'timeout' => 180, - 'connect_timeout' => 15, - 'verify' => false, - ]); + $this->service = new ExpertFinderService(); } /** * 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() { @@ -49,16 +39,12 @@ class ExpertFinder extends Base } try { - if ($source === 'pmc') { - $result = $this->searchViaPMC($keyword, $perPage, $minYear, $page); - } else { - $result = $this->searchViaPubMed($keyword, $perPage, $minYear, $page); - } + $result = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source); } catch (\Exception $e) { 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_exist'] = $saveResult['existing']; @@ -69,16 +55,6 @@ class ExpertFinder extends Base /** * 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() { @@ -171,9 +147,6 @@ class ExpertFinder extends Base /** * Update expert state - * Params: - * expert_id - single id or comma-separated ids - * state - new state (0-5) */ public function updateState() { @@ -195,9 +168,6 @@ class ExpertFinder extends Base /** * 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() { @@ -221,7 +191,6 @@ class ExpertFinder extends Base /** * Export search results to Excel - * Same params as search(), exports current page results */ public function export() { @@ -240,11 +209,7 @@ class ExpertFinder extends Base if (!$cached) { try { - if ($source === 'pmc') { - $cached = $this->searchViaPMC($keyword, $perPage, $minYear, $page); - } else { - $cached = $this->searchViaPubMed($keyword, $perPage, $minYear, $page); - } + $cached = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source); Cache::set($cacheKey, $cached, 3600); } catch (\Exception $e) { return jsonError('Search failed: ' . $e->getMessage()); @@ -276,16 +241,82 @@ class ExpertFinder extends Base // ==================== 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 - * 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() { @@ -303,39 +334,14 @@ class ExpertFinder extends Base $report = []; foreach ($keywords as $kw) { - if (empty($kw)) { - continue; - } - - $fetchLog = $this->getFetchLog($kw, $source); - $page = $fetchLog['last_page'] + 1; + if (empty($kw)) continue; try { - if ($source === 'pmc') { - $result = $this->searchViaPMC($kw, $perPage, $minYear, $page); - } 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'], - ]; + $result = $this->service->doFetchForField($kw, $source, $perPage, $minYear); + $report[] = $result; } catch (\Exception $e) { $report[] = [ 'keyword' => $kw, - 'page' => $page, 'error' => $e->getMessage(), ]; } @@ -357,7 +363,7 @@ class ExpertFinder extends Base } Cache::set($lockKey, 1, 300); - \think\Queue::push('app\api\job\FetchExperts', [ + \think\Queue::push('app\api\job\FetchExperts@fire', [ 'field' => $field, 'source' => 'pubmed', 'per_page' => 100, @@ -365,459 +371,17 @@ class ExpertFinder extends Base ], 'FetchExperts'); } - /** - * 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) + public function mytest() { - 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'], - ]; - } - - // ==================== 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', - ], + $data = $this->request->post(); + $rule = new Validate([ + "field" => "require" ]); - - $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); - } + if (!$rule->check($data)) { + return jsonError($rule->getError()); } - 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) { - $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; + $res = $this->service->doFetchForField($data['field'], "pubmed", 100, date('Y') - 3); + return jsonSuccess($res); } // ==================== Excel Export ==================== @@ -880,188 +444,4 @@ class ExpertFinder extends Base '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)); - } } diff --git a/application/api/controller/MailTemplate.php b/application/api/controller/MailTemplate.php new file mode 100644 index 0000000..1021eab --- /dev/null +++ b/application/api/controller/MailTemplate.php @@ -0,0 +1,253 @@ +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); + } +} + diff --git a/application/api/job/FetchExperts.php b/application/api/job/FetchExperts.php index df3a0ae..5162031 100644 --- a/application/api/job/FetchExperts.php +++ b/application/api/job/FetchExperts.php @@ -3,24 +3,48 @@ namespace app\api\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 { public function fire(Job $job, $data) { - try { - $finder = new \app\api\controller\ExpertFinder(); - $result = $finder->doFetchForField( - $data['field'], - $data['source'] ?? 'pubmed', - $data['per_page'] ?? 100, - $data['min_year'] ?? null + $field = isset($data['field']) ? $data['field'] : ''; +// $attempts = $job->attempts(); +// + $service = new ExpertFinderService(); +// $service->log('[FetchExperts] start field=' . $field . ' attempts=' . $attempts); +// +// 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)); - } catch (\Exception $e) { - Log::error('FetchExperts failed: ' . $e->getMessage()); - } +// $service->log('[FetchExperts] completed field=' . $field . ' result=' . json_encode($result)); +// } catch (\Throwable $e) { +// $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(); } diff --git a/application/api/job/PromotionSend.php b/application/api/job/PromotionSend.php new file mode 100644 index 0000000..89daedd --- /dev/null +++ b/application/api/job/PromotionSend.php @@ -0,0 +1,35 @@ +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(); + } +} diff --git a/application/common/ExpertFinderService.php b/application/common/ExpertFinderService.php new file mode 100644 index 0000000..150f93e --- /dev/null +++ b/application/common/ExpertFinderService.php @@ -0,0 +1,592 @@ +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); + } +} diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php new file mode 100644 index 0000000..c9dfa1e --- /dev/null +++ b/application/common/PromotionService.php @@ -0,0 +1,324 @@ +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); + } +} diff --git a/application/config.php b/application/config.php index c8b5393..ce1333f 100644 --- a/application/config.php +++ b/application/config.php @@ -172,8 +172,11 @@ return [ // 日志保存目录 'path' => LOG_PATH, // 日志记录级别 - 'level' => ['log'], - "max_files"=>3, + 'level' => ['error', 'warning', 'info', 'notice', 'debug'], + 'file_size' => 1024 * 1024 * 2, // 2MB + 'max_files'=>30, + 'apart_day' => true, + 'format' => '[%s][%s] %s', ], // +----------------------------------------------------------------------