diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index e544eba..c0fe36f 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -225,6 +225,9 @@ class EmailClient extends Base $this->resetDailyCountIfNeeded($account); + + + if ($account['today_sent'] >= $account['daily_limit']) { $account = $this->pickSmtpAccount($journalId); if (!$account) { @@ -247,6 +250,132 @@ class EmailClient extends Base } } + /** + * 测试邮件:按指定 mail_template + mail_style 渲染后发送一封到指定邮箱。 + * 渲染与正式推广任务一致(使用 PromotionService::renderFromTemplate,变量含 expert_* / journal_*)。 + * + * Params: + * journal_id (必填) + * template_id (必填) + * to_email (必填) + * style_id (选填,默认 0 表示不套 style 包裹) + * expert_id (选填,有则从库取专家填充变量;无则使用占位假数据) + * vars_json (选填,JSON 对象,合并覆盖上述变量) + * j_email_id (选填,指定 SMTP;否则自动选有余额的账号) + * skip_quota_count (选填,传 1 发送成功时不增加 today_sent,避免测试占额度) + */ + public function sendTemplateStyleTest() + { + $journalId = intval($this->request->param('journal_id', 0)); + $templateId = intval($this->request->param('template_id', 0)); + $styleId = intval($this->request->param('style_id', 0)); + $toEmail = trim($this->request->param('to_email', '')); + $expertId = intval($this->request->param('expert_id', 0)); + $varsJson = $this->request->param('vars_json', ''); + $accountId = intval($this->request->param('j_email_id', 0)); + $skipQuota = intval($this->request->param('skip_quota_count', 0)); + + if (!$journalId || !$templateId || $toEmail === '') { + return jsonError('journal_id, template_id and to_email are required'); + } + + $journal = Db::name('journal')->where('journal_id', $journalId)->find(); + if (!$journal) { + return jsonError('Journal not found'); + } + + $service = new PromotionService(); + $journalVars = $service->buildJournalVars($journal); + + if ($expertId > 0) { + $expert = Db::name('expert')->where('expert_id', $expertId)->find(); + if (!$expert) { + return jsonError('expert not found'); + } + if (empty($expert['field'])) { + $fieldRow = Db::name('expert_field') + ->where('expert_id', $expertId) + ->where('state', 0) + ->order('expert_field_id asc') + ->find(); + if ($fieldRow) { + $expert['field'] = $fieldRow['field']; + } + } + $expertVars = $service->buildExpertVars($expert); + } else { + $expertVars = [ + 'expert_title' => 'Dr.', + 'expert_name' => 'Test Recipient', + 'expert_email' => $toEmail, + 'expert_affiliation' => 'Example University / Hospital', + 'expert_field' => 'Clinical Research', + ]; + } + + $vars = array_merge($journalVars, $expertVars); + if ($varsJson !== '' && $varsJson !== null) { + $extra = json_decode($varsJson, true); + if (is_array($extra)) { + $vars = array_merge($vars, $extra); + } + } + + $rendered = $service->renderFromTemplate( + $templateId, + $journalId, + json_encode($vars, JSON_UNESCAPED_UNICODE), + $styleId + ); + + if ($rendered['code'] !== 0) { + return jsonError($rendered['msg']); + } + + $subject = $rendered['data']['subject']; + $content = $rendered['data']['body']; + + if ($accountId) { + $account = Db::name('journal_email') + ->where('j_email_id', $accountId) + ->where('journal_id', $journalId) + ->where('state', 0) + ->find(); + } else { + $account = $this->pickSmtpAccount($journalId); + } + + if (!$account) { + return jsonError('No available SMTP account (all disabled or daily limit reached)'); + } + + $this->resetDailyCountIfNeeded($account); + + if (!$skipQuota && $account['today_sent'] >= $account['daily_limit']) { + $account = $this->pickSmtpAccount($journalId); + if (!$account) { + return jsonError('All SMTP accounts have reached daily limit'); + } + } + + $result = $this->doSendEmail($account, $toEmail, $subject, $content); + + if ($result['status'] === 1) { + if (!$skipQuota) { + Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent'); + } + return jsonSuccess([ + 'msg' => 'Test email sent', + 'from' => $account['smtp_user'], + 'to' => $toEmail, + 'j_email_id' => $account['j_email_id'], + 'subject_sent' => $subject, + ]); + } + + return jsonError('Send failed: ' . $result['data']); + } + /** * Send batch emails to multiple experts * Params: journal_id, expert_ids (comma separated), @@ -1106,10 +1235,15 @@ class EmailClient extends Base if (!$journalId || !$templateId) { return jsonError('journal_id and template_id are required'); } + $journal_info = $this->journal_obj->where("journal_id",$journalId)->find(); // if (empty($expertIds) && empty($field)) { // return jsonError('expert_ids or field is required'); // } - + $templateId = ($templateId == 0) ? intval($journal_info['default_template_id']) : $templateId; + $styleId = ($styleId == 0) ? intval($journal_info['default_style_id']) : $styleId; + if($templateId==0){ + return jsonError("template is not set!"); + } $tpl = Db::name('mail_template') ->where('template_id', $templateId) ->where('journal_id', $journalId) @@ -1260,6 +1394,80 @@ class EmailClient extends Base ]); } + /**设置期刊默认 + * @return \think\response\Json + * @throws \think\Exception + * @throws \think\db\exception\DataNotFoundException + * @throws \think\db\exception\ModelNotFoundException + * @throws \think\exception\DbException + * @throws \think\exception\PDOException + */ + public function setDefaultPromotion(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id"=>"require", + "default_template_id"=>"require", + "default_style_id"=>"require", + "start_promotion"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $update['default_template_id']=$data['default_template_id']; + $update['default_style_id'] = $data['default_style_id']; + $update['start_promotion'] = $data['start_promotion']; + $this->journal_obj->where("journal_id",$data['journal_id'])->update($update); + $info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + + return jsonSuccess(['journal'=>$info]); + } + + + public function getPromotionJournalList(){ + $data = $this->request->post(); + $rule = new Validate([ + "user_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $journals = $this->journal_obj + ->field("t_journal.journal_id,t_journal.title,t_journal.issn,t_journal.default_template_id,t_journal.default_style_id,t_journal.start_promotion") + ->where("editor_id",$data['user_id'])->where("state",0)->select(); + //丰富期刊内容 + + + + return jsonSuccess(["list"=>$journals]); + } + + + + public function getPromotionJournalDetail(){ + $data = $this->request->post(); + $rule = new Validate([ + "journal_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $info = $this->journal_obj->where("journal_id",$data['journal_id'])->find(); + $template_info = []; + if($info['default_style_id']!==0){ + $template_info = Db::name("mail_template")->where("template_id",$info['default_template_id'])->find(); + } + $style_info = []; + if($info['default_style_id']!==0){ + $style_info = Db::name("mail_style")->where("style_id",$info['default_style_id'])->find(); + } + $re['journal'] = $info; + $re['template'] = $template_info; + $re['style'] = $style_info; + + return jsonSuccess($re); + } + + /** * 为单个任务预生成邮件(可手动或测试用) * Params: task_id @@ -1599,6 +1807,265 @@ class EmailClient extends Base return jsonSuccess(['bounce_logs_updated' => $updated]); } + // ==================== Cron: Auto Generate Daily Tasks ==================== + + /** + * 每日自动生成推广任务(由 Linux crontab 调用) + * + * 逻辑: + * 1. 查询所有 start_promotion=1 且 state=0 的期刊 + * 2. 对每个期刊,检查明天是否已存在任务,避免重复 + * 3. 用 default_template_id / default_style_id 创建 promotion_task + * 4. 根据期刊绑定的领域自动筛选专家(排除近7天已联系、排除非正常状态) + * 5. 每个期刊默认生成 100 封邮件 + * + * crontab 示例(每天凌晨1点执行): + * 0 1 * * * curl -s "https://your-domain.com/api/email_client/cronDailyCreateTasks" >> /var/log/promotion_cron.log 2>&1 + * + * 配合已有定时任务: + * 22:00 prepareTasksForDate — 预渲染邮件内容 + * 08:00 triggerTasksForDate — 开始发送 + */ + public function cronDailyCreateTasks() + { + set_time_limit(120); + + $sendDate = date('Y-m-d', strtotime('+1 day')); + $dailyLimit = 100; + $noRepeatDays = 7; + + $journals = Db::name('journal') + ->where('start_promotion', 1) + ->where('state', 0) + ->where('default_template_id', '>', 0) + ->select(); + + if (empty($journals)) { + return jsonSuccess(['msg' => 'No journals with promotion enabled', 'created' => 0]); + } + + $created = 0; + $skipped = 0; + $errors = []; + $details = []; + + foreach ($journals as $journal) { + $journalId = $journal['journal_id']; + $templateId = intval($journal['default_template_id']); + $styleId = intval(isset($journal['default_style_id']) ? $journal['default_style_id'] : 0); + + $existTask = Db::name('promotion_task') + ->where('journal_id', $journalId) + ->where('send_date', $sendDate) + ->where('state', 'in', [0, 1, 5]) + ->find(); + if ($existTask) { + $skipped++; + continue; + } + + $tpl = Db::name('mail_template') + ->where('template_id', $templateId) + ->where('journal_id', $journalId) + ->where('state', 0) + ->find(); + if (!$tpl) { + $errors[] = 'journal_id=' . $journalId . ': template_id=' . $templateId . ' not found'; + continue; + } + + $smtpCount = Db::name('journal_email') + ->where('journal_id', $journalId) + ->where('state', 0) + ->count(); + if ($smtpCount == 0) { + $errors[] = 'journal_id=' . $journalId . ': no active SMTP account'; + continue; + } + + $experts = $this->findEligibleExperts($journal, $noRepeatDays, $dailyLimit); + + if (empty($experts)) { + $details[] = [ + 'journal_id' => $journalId, + 'title' => $journal['title'], + 'status' => 'no_experts', + ]; + continue; + } + + $now = time(); + $scene = $tpl['scene'] ?? 'promotion'; + $taskId = Db::name('promotion_task')->insertGetId([ + 'journal_id' => $journalId, + 'template_id' => $templateId, + 'style_id' => $styleId, + 'scene' => $scene, + 'task_name' => 'Auto-' . ($journal['title'] ?? $journalId) . '-' . $sendDate, + 'total_count' => count($experts), + 'sent_count' => 0, + 'fail_count' => 0, + 'bounce_count' => 0, + 'state' => 0, + 'smtp_ids' => '', + 'min_interval' => 30, + 'max_interval' => 60, + 'max_bounce_rate' => 5, + 'no_repeat_days' => $noRepeatDays, + 'send_start_hour' => 8, + 'send_end_hour' => 22, + 'send_date' => $sendDate, + '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); + + $created++; + $details[] = [ + 'journal_id' => $journalId, + 'title' => $journal['title'], + 'task_id' => $taskId, + 'expert_count' => count($experts), + 'send_date' => $sendDate, + ]; + } + + return jsonSuccess([ + 'send_date' => $sendDate, + 'created' => $created, + 'skipped' => $skipped, + 'errors' => $errors, + 'details' => $details, + ]); + } + + /** + * 每日自动 prepare(预生成 subject/body),供 crontab 调用 + * + * 默认准备明天 send_date 的任务;可传 date=Y-m-d + * 建议每天 22:00 执行:curl .../EmailClient/cronDailyPrepareTasks + */ + public function cronDailyPrepareTasks() + { + $date = trim($this->request->param('date', '')); + if ($date === '') { + $date = date('Y-m-d', strtotime('+1 day')); + } else { + $ts = strtotime($date); + if ($ts === false) { + return jsonError('date invalid, use Y-m-d'); + } + $date = date('Y-m-d', $ts); + } + + $service = new PromotionService(); + $result = $service->prepareTasksForDate($date); + + return jsonSuccess($result); + } + + /** + * 每日自动触发发送(把 prepared 的任务启动),供 crontab 调用 + * + * 默认触发今天 send_date 的任务;可传 date=Y-m-d + * 会先对当天 state=0 的任务做一次补 prepare,再启动所有 state=5 的任务 + * 建议每天 08:00 执行:curl .../EmailClient/cronDailyTriggerTasks + */ + public function cronDailyTriggerTasks() + { + $date = trim($this->request->param('date', '')); + if ($date === '') { + $date = date('Y-m-d'); + } else { + $ts = strtotime($date); + if ($ts === false) { + return jsonError('date invalid, use Y-m-d'); + } + $date = date('Y-m-d', $ts); + } + + $service = new PromotionService(); + $result = $service->startTasksForDate($date); + + return jsonSuccess($result); + } + + + + public function act_task(){ + $service = new PromotionService(); + $res = $service->processNextEmail(2); + return jsonSuccess(['rr'=>$res]); + } + + /** + * 根据期刊绑定的领域自动筛选合适的专家 + */ + private function findEligibleExperts($journal, $noRepeatDays, $limit) + { + $issn = trim($journal['issn'] ?? ''); + + $majors = []; + if (!empty($issn)) { + $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.major_state', 0) + ->where('m.pid', '<>', 0) + ->column('m.major_title'); + $majors = array_unique(array_filter($majors)); + } + + $query = Db::name('expert')->alias('e') + ->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner') + ->where('e.state', 0) + ->where('ef.state', 0); + + if (!empty($majors)) { + $query->where(function ($q) use ($majors) { + foreach ($majors as $idx => $title) { + $title = trim($title); + if ($title === '') continue; + if ($idx === 0) { + $q->where('ef.field', 'like', '%' . $title . '%'); + } else { + $q->whereOr('ef.field', 'like', '%' . $title . '%'); + } + } + }); + } + + if ($noRepeatDays > 0) { + $cutoff = time() - ($noRepeatDays * 86400); + $query->where(function ($q) use ($cutoff) { + $q->where('e.ltime', 0)->whereOr('e.ltime', '<', $cutoff); + }); + } + + return $query + ->field('e.*') + ->group('e.expert_id') + ->limit($limit) + ->select(); + } + // ==================== Internal Methods ==================== /** diff --git a/application/api/controller/Journal.php b/application/api/controller/Journal.php index 16948da..000acf3 100644 --- a/application/api/controller/Journal.php +++ b/application/api/controller/Journal.php @@ -334,6 +334,14 @@ class Journal extends Base { $aJournalUpdate['wechat_app_secret'] = $update['wechat_app_secret']; } + if(isset($data['editor_name'])&&$data['editor_name']!=''){ + $update['editor_name'] = $data['editor_name']; + } + + if(isset($data['databases'])&&$data['databases']!=''){ + $update['databases'] = $data['databases']; + } + if(!empty($aJournalUpdate)){ $aJournalUpdate['issn'] = $journal_info['issn']; $sUrl = $this->sJournalUrl."wechat/Article/updateJournal"; diff --git a/application/api/job/PromotionSend.php b/application/api/job/PromotionSend.php index 89daedd..3dd5142 100644 --- a/application/api/job/PromotionSend.php +++ b/application/api/job/PromotionSend.php @@ -13,22 +13,22 @@ class PromotionSend $service = new PromotionService(); if (!$taskId) { - $service->log('[PromotionSend] missing task_id, job deleted'); +// $service->log('[PromotionSend] missing task_id, job deleted'); $job->delete(); return; } - try { +// try { $result = $service->processNextEmail($taskId); - $service->log('[PromotionSend] task=' . $taskId . ' result=' . json_encode($result)); +// $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()); - } +// 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/PromotionService.php b/application/common/PromotionService.php index ca8db4f..a9ebc79 100644 --- a/application/common/PromotionService.php +++ b/application/common/PromotionService.php @@ -128,7 +128,7 @@ class PromotionService '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('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, '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([ @@ -183,6 +183,7 @@ class PromotionService $failed = 0; $now = time(); + $journalVars = $this->buildJournalVars($journal); foreach ($logs as $log) { $expert = Db::name('expert')->where('expert_id', $log['expert_id'])->find(); if (!$expert) { @@ -196,7 +197,6 @@ class PromotionService } $expertVars = $this->buildExpertVars($expert); - $journalVars = $this->buildJournalVars($journal); $vars = array_merge($journalVars, $expertVars); $rendered = $this->renderFromTemplate( $task['template_id'], @@ -439,8 +439,8 @@ class PromotionService 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'] ?? ''; + $header = $style['header_html'] ? $this->renderVars($style['header_html'],$vars):''; + $footer = $style['footer_html'] ? $this->renderVars($style['footer_html'],$vars): ''; $finalBody = $header . $body . $footer; } } @@ -451,20 +451,33 @@ class PromotionService public function buildExpertVars($expert) { return [ - 'name' => $expert['name'] ?? '', - 'email' => $expert['email'] ?? '', - 'affiliation' => $expert['affiliation'] ?? '', - 'field' => $expert['field'] ?? '', + 'expert_title' => "Ph.D", + 'expert_name' => $expert['name'] ?? '', + 'expert_email' => $expert['email'] ?? '', + 'expert_affiliation' => $expert['affiliation'] ?? '', + 'expert_field' => $expert['field'] ?? '', ]; } public function buildJournalVars($journal) { if (!$journal) return []; + $zb = Db::name("board_to_journal") + ->where("journal_id",$journal['journal_id']) + ->where("state",0) + ->where('type',0) + ->find(); + return [ - 'journal_title' => $journal['title'] ?? '', + 'journal_name' => $journal['title'] ?? '', 'journal_abbr' => $journal['jabbr'] ?? '', 'journal_url' => $journal['website'] ?? '', + 'journal_email' => $journal['email'] ?? '', + 'indexing_databases' => $journal['databases'] ?? '', + 'submission_url' => "https://submission.tmrjournals.com/", + 'eic_name' => $zb['realname'] ?? '', + 'editor_name' => $journal['editor_name'], + 'special_support_deadline'=>date("Y-m-d",strtotime("+30 days")) ]; }