自动推广

This commit is contained in:
wangjinlei
2026-03-23 09:57:45 +08:00
parent d1e0f43992
commit 20a68ddc8a
4 changed files with 508 additions and 20 deletions

View File

@@ -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 ====================
/**

View File

@@ -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";

View File

@@ -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();
}

View File

@@ -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"))
];
}