修改自动推广的相关任务

This commit is contained in:
wangjinlei
2026-04-29 15:07:56 +08:00
parent 085bf5365c
commit 02242b1f08
9 changed files with 738 additions and 244 deletions

View File

@@ -5,6 +5,7 @@ namespace app\common;
use think\Db;
use think\Cache;
use think\Queue;
use think\Env;
use PHPMailer\PHPMailer\PHPMailer;
class PromotionService
@@ -61,30 +62,83 @@ class PromotionService
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) {
// 受众分发log.expert_id>0 → 外部 expert 库log.user_id>0 → 系统内部用户
$audienceKind = '';
$expert = null;
if (intval($logEntry['expert_id']) > 0) {
$audienceKind = 'expert';
$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'];
}
if (!empty($expert['unsubscribed'])) {
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
'state' => 2,
'error_msg' => 'Expert unsubscribed',
'send_time' => time(),
]);
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]);
$this->enqueueNextEmail($taskId, 2);
return ['done' => false, 'skipped' => $logEntry['email_to'], 'reason' => 'expert_unsubscribed'];
}
} elseif (intval($logEntry['user_id']) > 0) {
$audienceKind = 'user';
$row = Db::name('user')->alias('u')
->join('t_user_reviewer_info uri', 'uri.reviewer_id = u.user_id', 'left')
->where('u.user_id', $logEntry['user_id'])
->field('u.user_id, u.email, u.realname, u.unsubscribed, IFNULL(uri.company, "") as company')
->find();
if (!$row) {
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
'state' => 2,
'error_msg' => 'User not found',
'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' => 'user_invalid'];
}
if (!empty($row['unsubscribed'])) {
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
'state' => 2,
'error_msg' => 'User unsubscribed',
'send_time' => time(),
]);
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]);
$this->enqueueNextEmail($taskId, 2);
return ['done' => false, 'skipped' => $logEntry['email_to'], 'reason' => 'user_unsubscribed'];
}
$expert = [
'expert_id' => 0,
'user_id' => intval($row['user_id']),
'name' => (string)$row['realname'],
'email' => (string)$row['email'],
'affiliation' => (string)$row['company'],
];
} else {
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') . ')',
'error_msg' => 'No audience id (expert_id/user_id both empty)',
'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'];
return ['done' => false, 'skipped' => $logEntry['email_to'], 'reason' => 'no_audience_id'];
}
// 退订过滤(防止准备 → 发送之间窗口期内退订的人被误发
if (!empty($expert['unsubscribed'])) {
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
'state' => 2,
'error_msg' => 'Expert unsubscribed',
'send_time' => time(),
]);
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]);
$this->enqueueNextEmail($taskId, 2);
return ['done' => false, 'skipped' => $logEntry['email_to'], 'reason' => 'expert_unsubscribed'];
}
// 注入 role供模板 {{role}} 使用
$expert['role'] = $this->mapExpertTypeRole(intval($task['expert_type'] ?? 0));
$account = $this->pickSmtpAccountForTask($task);
if (!$account) {
@@ -101,49 +155,64 @@ class PromotionService
$subject = $logEntry['subject_prepared'];
$body = $logEntry['body_prepared'];
} else {
$journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find();
$expert_fields = Db::name('expert_field')
->where('expert_id', $expert['expert_id'])
->where('state', 0)
->order('expert_field_id desc')
->select();
$journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find();
$taskFields = $this->resolveTaskFields($task);
$taskFieldLower = [];
foreach ($taskFields as $tf) {
$tf = strtolower(trim($tf));
if ($tf !== '') $taskFieldLower[$tf] = true;
}
// 邀请青年编委申请链接(同 prepareSingleEmail 路径,保持模板变量一致)
$expert['application_link_yeditorial_board'] = self::buildYboardApplyUrl(
intval($expert['expert_id'] ?? 0),
intval($task['journal_id'])
);
$fieldSet = [];
$matchedTitle = '';
$fallbackTitle = '';
foreach ($expert_fields as $ef) {
$fn = trim($ef['field']);
if ($fn !== '' && !in_array($fn, $fieldSet)) {
$fieldSet[] = $fn;
}
$paper = trim((string)($ef['paper_title'] ?? ''));
if ($paper === '') continue;
if ($matchedTitle === '' && $fn !== '' && isset($taskFieldLower[strtolower($fn)])) {
$matchedTitle = $paper;
}
if ($fallbackTitle === '') {
$fallbackTitle = $paper;
}
if ($matchedTitle !== '' && $fallbackTitle !== '') break;
}
$expert['fields'] = implode(',', $fieldSet);
$expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle;
if ($audienceKind === 'expert') {
$expert_fields = Db::name('expert_field')
->where('expert_id', $expert['expert_id'])
->where('state', 0)
->order('expert_field_id desc')
->select();
// 现场发送路径:没有提前准备 LLM退回 .env 兜底文案
try {
$llmSvc = new PromotionLlmService();
$expert['llm_description'] = $llmSvc->getFallback();
$expert['ai_advised_topics'] = $llmSvc->getAdvisedFallback();
} catch (\Exception $e) {
$expert['llm_description'] = '';
$expert['ai_advised_topics'] = '';
$taskFields = $this->resolveTaskFields($task);
$taskFieldLower = [];
foreach ($taskFields as $tf) {
$tf = strtolower(trim($tf));
if ($tf !== '') $taskFieldLower[$tf] = true;
}
$fieldSet = [];
$matchedTitle = '';
$fallbackTitle = '';
foreach ($expert_fields as $ef) {
$fn = trim($ef['field']);
if ($fn !== '' && !in_array($fn, $fieldSet)) {
$fieldSet[] = $fn;
}
$paper = trim((string)($ef['paper_title'] ?? ''));
if ($paper === '') continue;
if ($matchedTitle === '' && $fn !== '' && isset($taskFieldLower[strtolower($fn)])) {
$matchedTitle = $paper;
}
if ($fallbackTitle === '') {
$fallbackTitle = $paper;
}
if ($matchedTitle !== '' && $fallbackTitle !== '') break;
}
$expert['fields'] = implode(',', $fieldSet);
$expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle;
// 现场发送路径:没有提前准备 LLM退回 .env 兜底文案
try {
$llmSvc = new PromotionLlmService();
$expert['llm_description'] = $llmSvc->getFallback();
$expert['ai_advised_topics'] = $llmSvc->getAdvisedFallback();
} catch (\Exception $e) {
$expert['llm_description'] = '';
$expert['ai_advised_topics'] = '';
}
} else {
// 内部受众:领域 / 代表作 / LLM 全部跳过
$expert['fields'] = '';
$expert['representative_work_title'] = '';
$expert['llm_description'] = '';
$expert['ai_advised_topics'] = '';
}
$expertVars = $this->buildExpertVars($expert);
@@ -184,7 +253,10 @@ 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(['state' => 1, 'ltime' => $now]);
// 仅外部 expert 库回写最近一次推广时间;内部 user 用 promotion_email_log.send_time 计频次
if ($audienceKind === 'expert' && intval($expert['expert_id']) > 0) {
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([
@@ -287,117 +359,186 @@ class PromotionService
return ['code' => 1, 'msg' => 'task_not_found', 'llm_status' => 0];
}
$expert = Db::name('expert')->where('expert_id', $log['expert_id'])->find();
if (!$expert) {
// 受众分发log.expert_id>0 → 外部 expert 库log.user_id>0 → 系统内部用户(编委/主编/...
$expertType = intval($task['expert_type'] ?? 0);
$audienceKind = '';
$expert = null;
if (intval($log['expert_id']) > 0) {
$audienceKind = 'expert';
$expert = Db::name('expert')->where('expert_id', $log['expert_id'])->find();
if (!$expert) {
Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2,
'error_msg' => 'Expert not found',
'send_time' => time(),
]);
$this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'expert_not_found', 'llm_status' => 0];
}
if (!empty($expert['unsubscribed'])) {
Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2,
'error_msg' => 'Expert unsubscribed',
'send_time' => time(),
]);
$this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'expert_unsubscribed', 'llm_status' => 0];
}
} elseif (intval($log['user_id']) > 0) {
$audienceKind = 'user';
$row = Db::name('user')->alias('u')
->join('t_user_reviewer_info uri', 'uri.reviewer_id = u.user_id', 'left')
->where('u.user_id', $log['user_id'])
->field('u.user_id, u.email, u.realname, u.unsubscribed, IFNULL(uri.company, "") as company')
->find();
if (!$row) {
Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2,
'error_msg' => 'User not found',
'send_time' => time(),
]);
$this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'user_not_found', 'llm_status' => 0];
}
if (!empty($row['unsubscribed'])) {
Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2,
'error_msg' => 'User unsubscribed',
'send_time' => time(),
]);
$this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'user_unsubscribed', 'llm_status' => 0];
}
// 对齐外部 expert 数据结构buildExpertVars 直接复用
$expert = [
'expert_id' => 0,
'user_id' => intval($row['user_id']),
'name' => (string)$row['realname'],
'email' => (string)$row['email'],
'affiliation' => (string)$row['company'],
];
} else {
Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2,
'error_msg' => 'Expert not found',
'error_msg' => 'No audience id (expert_id/user_id both empty)',
'send_time' => time(),
]);
$this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'expert_not_found', 'llm_status' => 0];
}
if (!empty($expert['unsubscribed'])) {
Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2,
'error_msg' => 'Expert unsubscribed',
'send_time' => time(),
]);
$this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'expert_unsubscribed', 'llm_status' => 0];
}
$expert_fields = Db::name('expert_field')
->where('expert_id', $expert['expert_id'])
->where('state', 0)
->order('expert_field_id desc')
->select();
// 领域优先级:任务所属工厂/期刊的目标领域
$taskFields = $this->resolveTaskFields($task);
$taskFieldLower = [];
foreach ($taskFields as $tf) {
$tf = strtolower(trim($tf));
if ($tf !== '') $taskFieldLower[$tf] = true;
}
$fieldSet = [];
$matchedTitle = '';
$fallbackTitle = '';
foreach ($expert_fields as $ef) {
$fn = trim($ef['field']);
if ($fn !== '' && !in_array($fn, $fieldSet)) {
$fieldSet[] = $fn;
}
$paper = trim((string)($ef['paper_title'] ?? ''));
if ($paper === '') continue;
// 匹配到任务领域的 paper 优先(按最新 expert_field_id desc 顺序取第一条)
if ($matchedTitle === '' && $fn !== '' && isset($taskFieldLower[strtolower($fn)])) {
$matchedTitle = $paper;
}
if ($fallbackTitle === '') {
$fallbackTitle = $paper;
}
if ($matchedTitle !== '' && $fallbackTitle !== '') break;
}
$expert['fields'] = implode(',', $fieldSet);
$expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle;
// 领域交集(大小写不敏感,保留 expert 原字面值用于展示)
$overlapFields = [];
if (!empty($taskFieldLower)) {
foreach ($fieldSet as $fn) {
if (isset($taskFieldLower[strtolower($fn)])) {
$overlapFields[] = $fn;
}
}
return ['code' => 1, 'msg' => 'no_audience_id', 'llm_status' => 0];
}
$journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find();
// 一次 LLM 调用生成两段内容description + advised_topics
$llmResult = [
'description' => '',
'description_status' => 0,
'advised_topics' => '',
'advised_topics_status' => 0,
];
try {
$llm = new PromotionLlmService();
$llmResult = $llm->generateEmailContent(
$expert,
$journal ?: [],
$overlapFields,
$taskFields,
$fieldSet
);
} catch (\Exception $e) {
// 兜底双占位($llm 实例可能未成功构建,单独拿一个尝试)
$fbDesc = '';
$fbAdvised = '';
try {
$fbSvc = isset($llm) ? $llm : new PromotionLlmService();
$fbDesc = $fbSvc->getFallback();
$fbAdvised = $fbSvc->getAdvisedFallback();
} catch (\Exception $ignore) {
// 忽略,使用空串
}
$llmResult = [
'description' => $fbDesc,
'description_status' => 2,
'advised_topics' => $fbAdvised,
'advised_topics_status' => 2,
];
$this->log("prepareSingleEmail log_id={$logId} llm_exception=" . $e->getMessage());
}
$llmText = (string)$llmResult['description'];
$llmStatus = intval($llmResult['description_status']);
$advisedText = (string)$llmResult['advised_topics'];
$advisedStatus = intval($llmResult['advised_topics_status']);
// 邀请青年编委申请链接(无 expert_id / 未配 APPLY_URL 时为空串,模板自行处理
$expert['application_link_yeditorial_board'] = self::buildYboardApplyUrl(
intval($expert['expert_id'] ?? 0),
intval($task['journal_id'])
);
$expert['llm_description'] = $llmText;
$expert['ai_advised_topics'] = $advisedText;
// 内部受众:跳过 LLMrepresentative_work_title / fields 给空串占位,模板侧不引用即可
if ($audienceKind === 'user') {
$expert['fields'] = '';
$expert['representative_work_title'] = '';
$expert['llm_description'] = '';
$expert['ai_advised_topics'] = '';
$expert['role'] = $this->mapExpertTypeRole($expertType);
$llmText = '';
$llmStatus = 0;
$advisedText = '';
$advisedStatus = 0;
} else {
// 外部 expert 库:领域 + 代表作 + LLM 完整流程
$expert_fields = Db::name('expert_field')
->where('expert_id', $expert['expert_id'])
->where('state', 0)
->order('expert_field_id desc')
->select();
// 领域优先级:任务所属工厂/期刊的目标领域
$taskFields = $this->resolveTaskFields($task);
$taskFieldLower = [];
foreach ($taskFields as $tf) {
$tf = strtolower(trim($tf));
if ($tf !== '') $taskFieldLower[$tf] = true;
}
$fieldSet = [];
$matchedTitle = '';
$fallbackTitle = '';
foreach ($expert_fields as $ef) {
$fn = trim($ef['field']);
if ($fn !== '' && !in_array($fn, $fieldSet)) {
$fieldSet[] = $fn;
}
$paper = trim((string)($ef['paper_title'] ?? ''));
if ($paper === '') continue;
// 匹配到任务领域的 paper 优先(按最新 expert_field_id desc 顺序取第一条)
if ($matchedTitle === '' && $fn !== '' && isset($taskFieldLower[strtolower($fn)])) {
$matchedTitle = $paper;
}
if ($fallbackTitle === '') {
$fallbackTitle = $paper;
}
if ($matchedTitle !== '' && $fallbackTitle !== '') break;
}
$expert['fields'] = implode(',', $fieldSet);
$expert['representative_work_title'] = $matchedTitle !== '' ? $matchedTitle : $fallbackTitle;
// 领域交集(大小写不敏感,保留 expert 原字面值用于展示)
$overlapFields = [];
if (!empty($taskFieldLower)) {
foreach ($fieldSet as $fn) {
if (isset($taskFieldLower[strtolower($fn)])) {
$overlapFields[] = $fn;
}
}
}
// 一次 LLM 调用生成两段内容description + advised_topics
$llmResult = [
'description' => '',
'description_status' => 0,
'advised_topics' => '',
'advised_topics_status' => 0,
];
try {
$llm = new PromotionLlmService();
$llmResult = $llm->generateEmailContent(
$expert,
$journal ?: [],
$overlapFields,
$taskFields,
$fieldSet
);
} catch (\Exception $e) {
$fbDesc = '';
$fbAdvised = '';
try {
$fbSvc = isset($llm) ? $llm : new PromotionLlmService();
$fbDesc = $fbSvc->getFallback();
$fbAdvised = $fbSvc->getAdvisedFallback();
} catch (\Exception $ignore) {
}
$llmResult = [
'description' => $fbDesc,
'description_status' => 2,
'advised_topics' => $fbAdvised,
'advised_topics_status' => 2,
];
$this->log("prepareSingleEmail log_id={$logId} llm_exception=" . $e->getMessage());
}
$llmText = (string)$llmResult['description'];
$llmStatus = intval($llmResult['description_status']);
$advisedText = (string)$llmResult['advised_topics'];
$advisedStatus = intval($llmResult['advised_topics_status']);
$expert['llm_description'] = $llmText;
$expert['ai_advised_topics'] = $advisedText;
$expert['role'] = $this->mapExpertTypeRole($expertType);
}
$expertVars = $this->buildExpertVars($expert);
$journalVars = $this->buildJournalVars($journal);
@@ -814,12 +955,15 @@ class PromotionService
$llm = $expert['llm_description'] ?? '';
$advised = $expert['ai_advised_topics'] ?? '';
// 退订 URL根据受众身份选择 kind=expert / user
$unsubUrl = '';
if (!empty($expert['expert_id']) && !empty($expert['email'])) {
$unsubUrl = \app\common\UnsubscribeService::buildUrl(
intval($expert['expert_id']),
(string)$expert['email']
);
$email = (string)($expert['email'] ?? '');
if ($email !== '') {
if (!empty($expert['expert_id'])) {
$unsubUrl = \app\common\UnsubscribeService::buildUrl('expert', intval($expert['expert_id']), $email);
} elseif (!empty($expert['user_id'])) {
$unsubUrl = \app\common\UnsubscribeService::buildUrl('user', intval($expert['user_id']), $email);
}
}
return [
@@ -829,14 +973,57 @@ class PromotionService
'expert_affiliation' => $expert['affiliation'] ?? '',
'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''),
'representative_work_title' => $expert['representative_work_title'] ?? '',
'role' => $expert['role'] ?? '',
'llm_description' => $llm,
'ai_content_analysis' => $llm,
'ai_advised_topics' => $advised,
'llm_advised_topics' => $advised,
'unsubscribe_url' => $unsubUrl,
'application_link_yeditorial_board' => $expert['application_link_yeditorial_board'] ?? '',
];
}
/**
* 构造"邀请青年编委申请"链接,供模板 {{application_link_yeditorial_board}} 使用。
*
* .env 配置([yboard] 段):
* APPLY_URL=https://your-domain.com/yboard/apply
*
* 输出格式:
* {APPLY_URL}?journal_id=X&expert_id=Y
* (若 APPLY_URL 已带 ? 参数则用 & 续接)
*
* 任意参数无效时返回空串,模板侧不会渲染出非法链接。
*/
public static function buildYboardApplyUrl($expertId, $journalId)
{
$expertId = intval($expertId);
$journalId = intval($journalId);
if ($expertId <= 0 || $journalId <= 0) return '';
$base = trim((string)Env::get('yboard.apply_url', ''));
if ($base === '') return '';
$sep = strpos($base, '?') === false ? '?' : '&';
return $base . $sep . 'journal_id=' . $journalId . '&expert_id=' . $expertId;
}
/**
* 把 expert_type 映射成英文角色文案,供模板 {{role}} 使用
*/
public function mapExpertTypeRole($expertType)
{
$map = [
1 => 'Editor-in-Chief',
2 => 'Editorial Board Member',
3 => 'Young Editorial Board Member',
4 => 'Author',
5 => '',
];
$expertType = intval($expertType);
return isset($map[$expertType]) ? $map[$expertType] : '';
}
public function buildJournalVars($journal)
{
if (!$journal) return [];