修改自动推广的相关任务

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

2
.env
View File

@@ -30,6 +30,8 @@ PROMOTION_LLM_ADVISED_FALLBACK=""
UNSUBSCRIBE_SECRET="TMR Unsubscribe Secret create on 20260427" UNSUBSCRIBE_SECRET="TMR Unsubscribe Secret create on 20260427"
UNSUBSCRIBE_BASE_URL=https://submission.tmrjournals.com/api/Unsubscribe/index UNSUBSCRIBE_BASE_URL=https://submission.tmrjournals.com/api/Unsubscribe/index
[yboard]
APPLY_URL="https://submission.tmrjournals.com/youthBoardRegister"
[journal] [journal]
;官网服务器地址 ;官网服务器地址

View File

@@ -437,6 +437,10 @@ class EmailClient extends Base
break; break;
} }
$expert['application_link_yeditorial_board'] = \app\common\PromotionService::buildYboardApplyUrl(
intval($expert['expert_id'] ?? 0),
intval($journalId)
);
$expertVars = $this->buildExpertVars($expert); $expertVars = $this->buildExpertVars($expert);
$vars = array_merge($journalVars, $expertVars); $vars = array_merge($journalVars, $expertVars);
@@ -1400,8 +1404,9 @@ class EmailClient extends Base
if (intval($factory['state']) !== 0) { if (intval($factory['state']) !== 0) {
return jsonError('Factory is disabled'); return jsonError('Factory is disabled');
} }
if (intval($factory['expert_type']) !== 5) { $expertType = intval($factory['expert_type']);
return jsonError('Only expert_type=5 is supported currently'); if (!in_array($expertType, [2, 3, 5], true)) {
return jsonError('Only expert_type=2(Editorial Board), 3(Young Editorial Board) or 5(Expert pool) is supported currently');
} }
$journalId = intval($factory['journal_id']); $journalId = intval($factory['journal_id']);
@@ -1461,19 +1466,30 @@ class EmailClient extends Base
} }
} }
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']); // 内部受众type∈{1..4})忽略领域 / 国家筛选;外部 expert 库type=5正常解析
if (empty($fields)) { if ($expertType === 5) {
return jsonError('No valid fields resolved from factory.fetch_ids'); $fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
if (empty($fields)) {
return jsonError('No valid fields resolved from factory.fetch_ids');
}
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
} else {
$fields = [];
$targetPartitions = '';
$targetCountryIds = '';
} }
$targetPartitions = trim((string)$factory['target_partitions']); $dailyLimit = max(1, intval($factory['send_count']));
$targetCountryIds = trim((string)$factory['target_country_ids']);
$dailyLimit = max(1, intval($factory['send_count']));
$experts = $this->findEligibleExperts($fields, $noRepeatDays, $dailyLimit, $targetPartitions, $targetCountryIds); $experts = $this->findEligibleExperts(
$fields, $noRepeatDays, $dailyLimit,
$targetPartitions, $targetCountryIds,
$expertType, $journalId
);
if (empty($experts)) { if (empty($experts)) {
return jsonError('No eligible experts found (all may have been promoted recently)'); return jsonError('No eligible audience found (all may have been contacted recently)');
} }
if ($taskName === '') { if ($taskName === '') {
@@ -1513,7 +1529,8 @@ class EmailClient extends Base
foreach ($experts as $expert) { foreach ($experts as $expert) {
$logs[] = [ $logs[] = [
'task_id' => $taskId, 'task_id' => $taskId,
'expert_id' => intval($expert['expert_id']), 'expert_id' => $expertType === 5 ? intval($expert['expert_id']) : 0,
'user_id' => $expertType === 5 ? 0 : intval($expert['user_id']),
'j_email_id' => 0, 'j_email_id' => 0,
'email_to' => $expert['email'], 'email_to' => $expert['email'],
'subject' => '', 'subject' => '',
@@ -2164,11 +2181,12 @@ class EmailClient extends Base
* 每日自动生成推广任务(由 Linux crontab 调用) * 每日自动生成推广任务(由 Linux crontab 调用)
* *
* 逻辑: * 逻辑:
* 1. 查询所有 state=0 的任务工厂(当前仅处理 expert_type=5 * 1. 查询所有 state=0 的任务工厂(支持 expert_type=2 编委 / =5 expert 库;其他类型预留
* 2. JOIN journal 确认期刊有效state=0, start_promotion=1 * 2. JOIN journal 确认期刊有效state=0, start_promotion=1
* 3. 按 factory_id + send_date 检查去重 * 3. 按 factory_id + send_date 检查去重
* 4. template/style: 工厂 > 0 用工厂的,否则用期刊默认 * 4. template/style: 工厂 > 0 用工厂的,否则用期刊默认
* 5. 用工厂的 fetch_ids 查领域,用工厂的 target_partitions/target_country_ids 做国家过滤 * 5. expert_type=5: 用 fetch_ids/partitions/country 选 t_expert
* expert_type=2: 直接按 journal_id 选 t_board_to_journal频次按 promotion_email_log 同 expert_type 维度扣除
* 6. 生成 promotion_task + promotion_email_log * 6. 生成 promotion_task + promotion_email_log
* *
* crontab 示例每天凌晨1点执行 * crontab 示例每天凌晨1点执行
@@ -2179,13 +2197,13 @@ class EmailClient extends Base
set_time_limit(120); set_time_limit(120);
$sendDate = date('Y-m-d', strtotime('+1 day')); $sendDate = date('Y-m-d', strtotime('+1 day'));
$noRepeatDays = 30; $noRepeatDaysDefault = 30;
$factories = Db::name('promotion_factory') $factories = Db::name('promotion_factory')
->alias('f') ->alias('f')
->join('t_journal j', 'j.journal_id = f.journal_id', 'inner') ->join('t_journal j', 'j.journal_id = f.journal_id', 'inner')
->where('f.state', 0) ->where('f.state', 0)
->where('f.expert_type', 5) ->where('f.expert_type', 'in', [2, 3, 5])
->where('j.state', 0) ->where('j.state', 0)
->where('f.start_promotion', 1) ->where('f.start_promotion', 1)
->field('f.*, j.title as journal_title, j.default_template_id, j.default_style_id') ->field('f.*, j.title as journal_title, j.default_template_id, j.default_style_id')
@@ -2248,24 +2266,39 @@ class EmailClient extends Base
} }
} }
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']); $expertType = intval($factory['expert_type']);
if (empty($fields)) {
$errors[] = 'factory_id=' . $factoryId . ': no valid fields from fetch_ids'; // 内部受众type∈{1..4}):默认 60 天频次(约稿场景);外部 expert 库type=5沿用 30 天
continue; $noRepeatDays = $expertType === 5 ? $noRepeatDaysDefault : 60;
if ($expertType === 5) {
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
if (empty($fields)) {
$errors[] = 'factory_id=' . $factoryId . ': no valid fields from fetch_ids';
continue;
}
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
} else {
$fields = [];
$targetPartitions = '';
$targetCountryIds = '';
} }
$targetPartitions = trim((string)$factory['target_partitions']); $dailyLimit = max(1, intval($factory['send_count']));
$targetCountryIds = trim((string)$factory['target_country_ids']);
$dailyLimit = max(1, intval($factory['send_count']));
$experts = $this->findEligibleExperts($fields, $noRepeatDays, $dailyLimit, $targetPartitions, $targetCountryIds); $experts = $this->findEligibleExperts(
$fields, $noRepeatDays, $dailyLimit,
$targetPartitions, $targetCountryIds,
$expertType, $journalId
);
if (empty($experts)) { if (empty($experts)) {
$details[] = [ $details[] = [
'factory_id' => $factoryId, 'factory_id' => $factoryId,
'journal_id' => $journalId, 'journal_id' => $journalId,
'title' => $factory['journal_title'], 'title' => $factory['journal_title'],
'status' => 'no_experts', 'status' => 'no_audience',
]; ];
continue; continue;
} }
@@ -2304,7 +2337,8 @@ class EmailClient extends Base
foreach ($experts as $expert) { foreach ($experts as $expert) {
$logs[] = [ $logs[] = [
'task_id' => $taskId, 'task_id' => $taskId,
'expert_id' => intval($expert['expert_id']), 'expert_id' => $expertType === 5 ? intval($expert['expert_id']) : 0,
'user_id' => $expertType === 5 ? 0 : intval($expert['user_id']),
'j_email_id' => 0, 'j_email_id' => 0,
'email_to' => $expert['email'], 'email_to' => $expert['email'],
'subject' => '', 'subject' => '',
@@ -2459,17 +2493,140 @@ class EmailClient extends Base
} }
/** /**
* 根据领域列表 + 国家筛选 + noRepeatDays 筛选合适的专家 * 预览某工厂当前可发送的受众(已扣除频次内已发的)
* 仅返回元信息,不创建任务、不修改任何记录。便于上线前肉眼审核。
* *
* @param array $fields 领域名称数组(由调用方从 factory.fetch_ids 或 journal_promotion_field 查好传入) * 入参:
* @param int $noRepeatDays 同一专家N天内不重复 * promotion_factory_id (必填)
* no_repeat_days (可选,默认按 expert_type 取5→30其他→60)
* sample_size (可选,前 N 条样本,默认 10最大 50)
*
* 返回:
* {
* factory_id, journal_id, expert_type, expert_type_label,
* no_repeat_days, daily_limit,
* eligible_count, // 实际可发数(已扣频次)
* samples: [ { name, email, affiliation, ... } ]
* }
*/
public function previewFactoryAudience()
{
$factoryId = intval($this->request->param('promotion_factory_id', 0));
$sampleSize = intval($this->request->param('sample_size', 10));
$sampleSize = max(1, min(50, $sampleSize));
if (!$factoryId) {
return jsonError('promotion_factory_id is required');
}
$factory = Db::name('promotion_factory')->where('promotion_factory_id', $factoryId)->find();
if (!$factory) {
return jsonError('Factory not found');
}
$journalId = intval($factory['journal_id']);
$expertType = intval($factory['expert_type']);
$dailyLimit = max(1, intval($factory['send_count']));
// 默认频次expert=30天内部=60天
$noRepeatDaysDefault = $expertType === 5 ? 30 : 60;
$noRepeatDays = intval($this->request->param('no_repeat_days', $noRepeatDaysDefault));
if ($expertType === 5) {
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
$targetPartitions = trim((string)$factory['target_partitions']);
$targetCountryIds = trim((string)$factory['target_country_ids']);
if (empty($fields)) {
return jsonSuccess([
'factory_id' => $factoryId,
'journal_id' => $journalId,
'expert_type' => $expertType,
'expert_type_label' => $this->labelExpertType($expertType),
'no_repeat_days' => $noRepeatDays,
'daily_limit' => $dailyLimit,
'eligible_count' => 0,
'samples' => [],
'note' => 'No valid fields resolved from factory.fetch_ids',
]);
}
} else {
$fields = [];
$targetPartitions = '';
$targetCountryIds = '';
}
// 取一个稍大的窗口用于估算总人数 + 样本(不一定 == dailyLimit
$previewLimit = max($dailyLimit, $sampleSize);
$audience = $this->findEligibleExperts(
$fields, $noRepeatDays, $previewLimit,
$targetPartitions, $targetCountryIds,
$expertType, $journalId
);
$samples = [];
foreach (array_slice($audience, 0, $sampleSize) as $a) {
$samples[] = [
'name' => $a['name'] ?? '',
'email' => $a['email'] ?? '',
'affiliation' => $a['affiliation'] ?? '',
'expert_id' => intval($a['expert_id'] ?? 0),
'user_id' => intval($a['user_id'] ?? 0),
];
}
return jsonSuccess([
'factory_id' => $factoryId,
'journal_id' => $journalId,
'expert_type' => $expertType,
'expert_type_label' => $this->labelExpertType($expertType),
'no_repeat_days' => $noRepeatDays,
'daily_limit' => $dailyLimit,
'eligible_count' => count($audience),
'samples' => $samples,
]);
}
private function labelExpertType($t)
{
$map = [
1 => 'Editor-in-Chief',
2 => 'Editorial Board',
3 => 'Young Editorial Board',
4 => 'Author',
5 => 'Expert Pool',
];
return isset($map[intval($t)]) ? $map[intval($t)] : 'Unknown';
}
/**
* 根据 expert_type 分发选人逻辑
*
* - expert_type = 5从 t_expert 库选人(按领域 / 国家 / 频次)
* - expert_type ∈ {1,2,3,4}:从系统内部表选人(主编/编委/青年编委/作者fields 与国家筛选忽略;
* 频次按 t_promotion_email_log 中相同 expert_type 维度的最近发送时间扣除
*
* 返回行 shape 已对齐:
* - type=5 行包含 e.* 全部字段(含 expert_id、country_id、ltime 等)
* - type∈{1..4} 行至少包含 user_id / expert_id=0 / name / email / affiliation / fields=''
*
* @param array $fields 领域名称数组(仅 type=5 生效)
* @param int $noRepeatDays 同一受众 N 天内不重复
* @param int $limit 最大返回数 * @param int $limit 最大返回数
* @param string $targetPartitions 国家分区,逗号分隔 * @param string $targetPartitions 国家分区,逗号分隔(仅 type=5 生效)
* @param string $targetCountryIds 单独指定的 country_id逗号分隔 * @param string $targetCountryIds 单独指定的 country_id逗号分隔(仅 type=5 生效)
* @param int $expertType 受众类型;默认 5 兼容老调用
* @param int $journalId 期刊 IDtype∈{1..4} 必填)
* @return array * @return array
*/ */
private function findEligibleExperts($fields, $noRepeatDays, $limit, $targetPartitions = '', $targetCountryIds = '') private function findEligibleExperts($fields, $noRepeatDays, $limit, $targetPartitions = '', $targetCountryIds = '', $expertType = 5, $journalId = 0)
{ {
$expertType = intval($expertType);
$journalId = intval($journalId);
if ($expertType !== 5) {
return $this->selectInternalAudience($expertType, $journalId, $noRepeatDays, $limit);
}
$fields = array_values(array_unique(array_filter(array_map('trim', $fields)))); $fields = array_values(array_unique(array_filter(array_map('trim', $fields))));
if (empty($fields)) { if (empty($fields)) {
@@ -2511,6 +2668,84 @@ class EmailClient extends Base
->select(); ->select();
} }
/**
* 系统内部受众选人(编委 / 主编 / 青年编委 / 作者)
* 仅按 期刊 + 频次 过滤;领域 / 国家无关
*
* 频次:扣除「同 expert_type 维度下no_repeat_days 内已经发出 (state=1) 或退信 (state=3) 的人」
*
* @param int $expertType 1=主编 2=编委 3=青年编委 4=作者
* @param int $journalId
* @param int $noRepeatDays
* @param int $limit
* @return array
*/
private function selectInternalAudience($expertType, $journalId, $noRepeatDays, $limit)
{
if ($journalId <= 0 || $limit <= 0) return [];
switch ($expertType) {
case 2: // 编委
$query = Db::name('board_to_journal')->alias('b')
->join('t_user u', 'u.user_id = b.user_id', 'inner')
->join('t_user_reviewer_info uri', 'uri.reviewer_id = u.user_id', 'left')
->where('b.journal_id', $journalId)
->where('b.state', 0)
->where('u.email', '<>', '')
->where('u.unsubscribed', 0);
break;
case 3: // 青年编委只取当前在任start_date <= now <= end_date
$now = time();
$query = Db::name('user_to_yboard')->alias('y')
->join('t_user u', 'u.user_id = y.user_id', 'inner')
->join('t_user_reviewer_info uri', 'uri.reviewer_id = u.user_id', 'left')
->where('y.journal_id', $journalId)
->where('y.state', 0)
->where('y.start_date', '<=', $now)
->where('y.end_date', '>=', $now)
->where('u.email', '<>', '')
->where('u.unsubscribed', 0);
break;
case 1: // 主编(预留,本期不实现)
case 4: // 作者(预留)
default:
return [];
}
if ($noRepeatDays > 0) {
$cutoff = intval(time() - ($noRepeatDays * 86400));
$expertTypeSafe = intval($expertType);
// 关联子查询:相对于 NOT IN避免把全部已发 user_id 拉到 PHP 再拼回 SQL
// 配合 t_promotion_email_log(user_id, send_time) 复合索引做半连接探针,常量时间。
$query->where(function ($q) use ($expertTypeSafe, $cutoff) {
$q->table('t_promotion_email_log')->alias('l')
->join('t_promotion_task t', 't.task_id = l.task_id', 'inner')
->where('t.expert_type', $expertTypeSafe)
->where('l.state', 'in', [1, 3])
->where('l.send_time', '>', $cutoff)
->whereRaw('l.user_id = u.user_id');
}, 'not exists');
}
$rows = $query
->field('u.user_id, u.email, u.realname AS name, IFNULL(uri.company, "") AS affiliation')
->group('u.user_id')
->limit($limit)
->select();
// 对齐外部 expert 行 shape下游无需再做分支判空
foreach ($rows as &$r) {
$r['expert_id'] = 0;
$r['fields'] = '';
$r['country_id'] = 0;
}
unset($r);
return $rows;
}
private function publishInboxUpdatedEvent($accountId, $payload) private function publishInboxUpdatedEvent($accountId, $payload)
{ {
$file = $this->getInboxSseEventFile($accountId); $file = $this->getInboxSseEventFile($accountId);
@@ -2636,12 +2871,14 @@ class EmailClient extends Base
private function buildExpertVars($expert) private function buildExpertVars($expert)
{ {
$email = (string)($expert['email'] ?? '');
$unsubUrl = ''; $unsubUrl = '';
if (!empty($expert['expert_id']) && !empty($expert['email'])) { if ($email !== '') {
$unsubUrl = \app\common\UnsubscribeService::buildUrl( if (!empty($expert['expert_id'])) {
intval($expert['expert_id']), $unsubUrl = \app\common\UnsubscribeService::buildUrl('expert', intval($expert['expert_id']), $email);
(string)$expert['email'] } elseif (!empty($expert['user_id'])) {
); $unsubUrl = \app\common\UnsubscribeService::buildUrl('user', intval($expert['user_id']), $email);
}
} }
return [ return [
@@ -2650,6 +2887,7 @@ class EmailClient extends Base
'affiliation' => $expert['affiliation'] ?? '', 'affiliation' => $expert['affiliation'] ?? '',
'field' => $expert['field'] ?? '', 'field' => $expert['field'] ?? '',
'unsubscribe_url' => $unsubUrl, 'unsubscribe_url' => $unsubUrl,
'application_link_yeditorial_board' => $expert['application_link_yeditorial_board'] ?? '',
]; ];
} }

View File

@@ -298,7 +298,7 @@ class ExpertFinder extends Base
*/ */
public function dailyFetchAll() public function dailyFetchAll()
{ {
$perPage = max(10, intval($this->request->param('per_page', 50))); $perPage = max(10, intval($this->request->param('per_page', 10)));
$source = $this->request->param('source', 'pubmed'); $source = $this->request->param('source', 'pubmed');
$minYear = intval($this->request->param('min_year', date('Y') - 3)); $minYear = intval($this->request->param('min_year', date('Y') - 3));

View File

@@ -376,6 +376,14 @@ class Ucenter extends Base{
if(isset($data['phone'])&&$data['phone']!=''){ if(isset($data['phone'])&&$data['phone']!=''){
$update['phone'] = $data['phone']; $update['phone'] = $data['phone'];
} }
$old = $this->user_obj->where(['user_id'=>$data['user_id']])->find();
if(isset($data['email'])&&$data['email']!=$old['email']){
$c = $this->user_obj->where("email",trim($data['email']))->find();
if($c){
return jsonError("Email already exists");
}
$update['email'] = trim($data['email']);
}
$update['localname'] = isset($data['localname'])?trim($data['localname']):''; $update['localname'] = isset($data['localname'])?trim($data['localname']):'';
$this->user_obj->where(['user_id'=>$data['user_id']])->update($update); $this->user_obj->where(['user_id'=>$data['user_id']])->update($update);
$updata1=[ $updata1=[

View File

@@ -8,14 +8,17 @@ use think\Response;
use app\common\UnsubscribeService; use app\common\UnsubscribeService;
/** /**
* 推广邮件退订入口(公开访问,无需登录) * 推广 / 约稿邮件退订入口(公开访问,无需登录)
* *
* 路由: * 路由:
* GET /api/Unsubscribe/index?id=&t= 展示确认页 * GET /api/Unsubscribe/index?k=&id=&t= 展示确认页
* POST /api/Unsubscribe/confirm?id=&t= 执行退订 * POST /api/Unsubscribe/confirm?k=&id=&t= 执行退订
*
* k = expert → 操作 t_expert.unsubscribed
* k = user → 操作 t_user.unsubscribed
* *
* 安全: * 安全:
* - URL 内带 sha256 签名 (id, email, secret),防伪造 * - URL 内带 sha256 签名 (kind, id, email, secret),防伪造
* - 必须确认页二次点击才执行(防爬虫预取链接误退订) * - 必须确认页二次点击才执行(防爬虫预取链接误退订)
*/ */
class Unsubscribe extends Controller class Unsubscribe extends Controller
@@ -25,51 +28,77 @@ class Unsubscribe extends Controller
*/ */
public function index() public function index()
{ {
$kind = UnsubscribeService::normalizeKind($this->request->param('k', UnsubscribeService::KIND_EXPERT));
$id = intval($this->request->param('id', 0)); $id = intval($this->request->param('id', 0));
$token = trim((string)$this->request->param('t', '')); $token = trim((string)$this->request->param('t', ''));
$expert = $id > 0 ? Db::name('expert')->where('expert_id', $id)->find() : null; $audience = $this->loadAudience($kind, $id);
if (!$expert) { if (!$audience) {
return $this->html($this->pageInvalid(), 404); return $this->html($this->pageInvalid(), 404);
} }
if (!UnsubscribeService::verifyToken($id, (string)$expert['email'], $token)) { if (!UnsubscribeService::verifyToken($kind, $id, (string)$audience['email'], $token)) {
return $this->html($this->pageInvalid(), 403); return $this->html($this->pageInvalid(), 403);
} }
if (!empty($expert['unsubscribed'])) { if (!empty($audience['unsubscribed'])) {
return $this->html($this->pageAlreadyDone((string)$expert['email'])); return $this->html($this->pageAlreadyDone((string)$audience['email']));
} }
return $this->html($this->pageConfirm( return $this->html($this->pageConfirm(
$kind,
$id, $id,
$token, $token,
(string)$expert['email'], (string)$audience['email'],
(string)($expert['name'] ?? '') (string)$audience['name']
)); ));
} }
/** /**
* 真正执行退订POST 推荐GET 也允许,以兼容部分邮件客户端禁止表单提交 * 真正执行退订POST 推荐GET 也允许)
*/ */
public function confirm() public function confirm()
{ {
$kind = UnsubscribeService::normalizeKind($this->request->param('k', UnsubscribeService::KIND_EXPERT));
$id = intval($this->request->param('id', 0)); $id = intval($this->request->param('id', 0));
$token = trim((string)$this->request->param('t', '')); $token = trim((string)$this->request->param('t', ''));
$expert = $id > 0 ? Db::name('expert')->where('expert_id', $id)->find() : null; $audience = $this->loadAudience($kind, $id);
if (!$expert) { if (!$audience) {
return $this->html($this->pageInvalid(), 404); return $this->html($this->pageInvalid(), 404);
} }
if (!UnsubscribeService::verifyToken($id, (string)$expert['email'], $token)) { if (!UnsubscribeService::verifyToken($kind, $id, (string)$audience['email'], $token)) {
return $this->html($this->pageInvalid(), 403); return $this->html($this->pageInvalid(), 403);
} }
if (empty($expert['unsubscribed'])) { if (empty($audience['unsubscribed'])) {
Db::name('expert')->where('expert_id', $id)->update([ $table = $kind === UnsubscribeService::KIND_USER ? 'user' : 'expert';
$pk = $kind === UnsubscribeService::KIND_USER ? 'user_id' : 'expert_id';
Db::name($table)->where($pk, $id)->update([
'unsubscribed' => 1, 'unsubscribed' => 1,
]); ]);
} }
return $this->html($this->pageSuccess((string)$expert['email'])); return $this->html($this->pageSuccess((string)$audience['email']));
}
/**
* 按 kind 加载受众的最少必要信息id, email, name, unsubscribed
*/
private function loadAudience($kind, $id)
{
if ($id <= 0) return null;
if ($kind === UnsubscribeService::KIND_USER) {
$row = Db::name('user')
->where('user_id', $id)
->field('user_id, email, realname AS name, unsubscribed')
->find();
} else {
$row = Db::name('expert')
->where('expert_id', $id)
->field('expert_id, email, name, unsubscribed')
->find();
}
return $row ?: null;
} }
// ==================== HTML 页面 ==================== // ==================== HTML 页面 ====================
@@ -107,8 +136,9 @@ class Unsubscribe extends Controller
. '<body><div class="wrap">' . $bodyHtml . '</div></body></html>'; . '<body><div class="wrap">' . $bodyHtml . '</div></body></html>';
} }
private function pageConfirm($id, $token, $email, $name) private function pageConfirm($kind, $id, $token, $email, $name)
{ {
$kindSafe = htmlspecialchars($kind, ENT_QUOTES, 'UTF-8');
$idSafe = intval($id); $idSafe = intval($id);
$tokenSafe = htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); $tokenSafe = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
$emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); $emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8');
@@ -120,6 +150,7 @@ class Unsubscribe extends Controller
. '</span> from all promotion and invitation emails sent by TMR Journals. ' . '</span> from all promotion and invitation emails sent by TMR Journals. '
. 'After unsubscribing you will no longer receive marketing emails from us.</p>' . 'After unsubscribing you will no longer receive marketing emails from us.</p>'
. '<form method="post" action="confirm" style="margin-top:24px;">' . '<form method="post" action="confirm" style="margin-top:24px;">'
. '<input type="hidden" name="k" value="' . $kindSafe . '">'
. '<input type="hidden" name="id" value="' . $idSafe . '">' . '<input type="hidden" name="id" value="' . $idSafe . '">'
. '<input type="hidden" name="t" value="' . $tokenSafe . '">' . '<input type="hidden" name="t" value="' . $tokenSafe . '">'
. '<button type="submit" class="btn btn-primary">Confirm unsubscribe</button>' . '<button type="submit" class="btn btn-primary">Confirm unsubscribe</button>'

View File

@@ -24,16 +24,12 @@ class PromotionPrepareEmail
{ {
$logId = intval(isset($data['log_id']) ? $data['log_id'] : 0); $logId = intval(isset($data['log_id']) ? $data['log_id'] : 0);
$service = new PromotionService(); $service = new PromotionService();
$service->log('[PromotionPrepareEmail] m11ylog=' . $logId);
if (!$logId) { if (!$logId) {
$job->delete(); $job->delete();
return; return;
} }
$result = $service->prepareSingleEmail($logId); $service->prepareSingleEmail($logId);
$service->log('[PromotionPrepareEm111ail] m11ylog=' . $logId); $job->delete();
// //
// try { // try {
// $result = $service->prepareSingleEmail($logId); // $result = $service->prepareSingleEmail($logId);

View File

@@ -5,6 +5,7 @@ namespace app\common;
use think\Db; use think\Db;
use think\Cache; use think\Cache;
use think\Queue; use think\Queue;
use think\Env;
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
class PromotionService class PromotionService
@@ -61,30 +62,83 @@ class PromotionService
return ['done' => true, 'reason' => 'all_emails_processed']; return ['done' => true, 'reason' => 'all_emails_processed'];
} }
$expert = Db::name('expert')->where('expert_id', $logEntry['expert_id'])->find(); // 受众分发log.expert_id>0 → 外部 expert 库log.user_id>0 → 系统内部用户
if (!$expert || $expert['state'] == 4 || $expert['state'] == 5) { $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([ Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([
'state' => 2, '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(), 'send_time' => time(),
]); ]);
Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count'); Db::name('promotion_task')->where('task_id', $taskId)->setInc('fail_count');
Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]); Db::name('promotion_task')->where('task_id', $taskId)->update(['utime' => time()]);
$this->enqueueNextEmail($taskId, 2); $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'];
} }
// 退订过滤(防止准备 → 发送之间窗口期内退订的人被误发 // 注入 role供模板 {{role}} 使用
if (!empty($expert['unsubscribed'])) { $expert['role'] = $this->mapExpertTypeRole(intval($task['expert_type'] ?? 0));
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'];
}
$account = $this->pickSmtpAccountForTask($task); $account = $this->pickSmtpAccountForTask($task);
if (!$account) { if (!$account) {
@@ -101,49 +155,64 @@ class PromotionService
$subject = $logEntry['subject_prepared']; $subject = $logEntry['subject_prepared'];
$body = $logEntry['body_prepared']; $body = $logEntry['body_prepared'];
} else { } else {
$journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); $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();
$taskFields = $this->resolveTaskFields($task); // 邀请青年编委申请链接(同 prepareSingleEmail 路径,保持模板变量一致)
$taskFieldLower = []; $expert['application_link_yeditorial_board'] = self::buildYboardApplyUrl(
foreach ($taskFields as $tf) { intval($expert['expert_id'] ?? 0),
$tf = strtolower(trim($tf)); intval($task['journal_id'])
if ($tf !== '') $taskFieldLower[$tf] = true; );
}
$fieldSet = []; if ($audienceKind === 'expert') {
$matchedTitle = ''; $expert_fields = Db::name('expert_field')
$fallbackTitle = ''; ->where('expert_id', $expert['expert_id'])
foreach ($expert_fields as $ef) { ->where('state', 0)
$fn = trim($ef['field']); ->order('expert_field_id desc')
if ($fn !== '' && !in_array($fn, $fieldSet)) { ->select();
$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 兜底文案 $taskFields = $this->resolveTaskFields($task);
try { $taskFieldLower = [];
$llmSvc = new PromotionLlmService(); foreach ($taskFields as $tf) {
$expert['llm_description'] = $llmSvc->getFallback(); $tf = strtolower(trim($tf));
$expert['ai_advised_topics'] = $llmSvc->getAdvisedFallback(); if ($tf !== '') $taskFieldLower[$tf] = true;
} catch (\Exception $e) { }
$expert['llm_description'] = '';
$expert['ai_advised_topics'] = ''; $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); $expertVars = $this->buildExpertVars($expert);
@@ -184,7 +253,10 @@ class PromotionService
'send_time' => $now, 'send_time' => $now,
]); ]);
Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent'); Db::name('journal_email')->where('j_email_id', $account['j_email_id'])->setInc('today_sent');
Db::name('expert')->where('expert_id', $expert['expert_id'])->update(['state' => 1, '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'); Db::name('promotion_task')->where('task_id', $taskId)->setInc('sent_count');
} else { } else {
Db::name('promotion_email_log')->where('log_id', $logEntry['log_id'])->update([ 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]; return ['code' => 1, 'msg' => 'task_not_found', 'llm_status' => 0];
} }
$expert = Db::name('expert')->where('expert_id', $log['expert_id'])->find(); // 受众分发log.expert_id>0 → 外部 expert 库log.user_id>0 → 系统内部用户(编委/主编/...
if (!$expert) { $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([ Db::name('promotion_email_log')->where('log_id', $logId)->update([
'state' => 2, 'state' => 2,
'error_msg' => 'Expert not found', 'error_msg' => 'No audience id (expert_id/user_id both empty)',
'send_time' => time(), 'send_time' => time(),
]); ]);
$this->tryFinalizeTask($task['task_id']); $this->tryFinalizeTask($task['task_id']);
return ['code' => 1, 'msg' => 'expert_not_found', 'llm_status' => 0]; return ['code' => 1, 'msg' => 'no_audience_id', '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;
}
}
} }
$journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find();
// 一次 LLM 调用生成两段内容description + advised_topics // 邀请青年编委申请链接(无 expert_id / 未配 APPLY_URL 时为空串,模板自行处理
$llmResult = [ $expert['application_link_yeditorial_board'] = self::buildYboardApplyUrl(
'description' => '', intval($expert['expert_id'] ?? 0),
'description_status' => 0, intval($task['journal_id'])
'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['llm_description'] = $llmText; // 内部受众:跳过 LLMrepresentative_work_title / fields 给空串占位,模板侧不引用即可
$expert['ai_advised_topics'] = $advisedText; 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); $expertVars = $this->buildExpertVars($expert);
$journalVars = $this->buildJournalVars($journal); $journalVars = $this->buildJournalVars($journal);
@@ -814,12 +955,15 @@ class PromotionService
$llm = $expert['llm_description'] ?? ''; $llm = $expert['llm_description'] ?? '';
$advised = $expert['ai_advised_topics'] ?? ''; $advised = $expert['ai_advised_topics'] ?? '';
// 退订 URL根据受众身份选择 kind=expert / user
$unsubUrl = ''; $unsubUrl = '';
if (!empty($expert['expert_id']) && !empty($expert['email'])) { $email = (string)($expert['email'] ?? '');
$unsubUrl = \app\common\UnsubscribeService::buildUrl( if ($email !== '') {
intval($expert['expert_id']), if (!empty($expert['expert_id'])) {
(string)$expert['email'] $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 [ return [
@@ -829,14 +973,57 @@ class PromotionService
'expert_affiliation' => $expert['affiliation'] ?? '', 'expert_affiliation' => $expert['affiliation'] ?? '',
'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''), 'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''),
'representative_work_title' => $expert['representative_work_title'] ?? '', 'representative_work_title' => $expert['representative_work_title'] ?? '',
'role' => $expert['role'] ?? '',
'llm_description' => $llm, 'llm_description' => $llm,
'ai_content_analysis' => $llm, 'ai_content_analysis' => $llm,
'ai_advised_topics' => $advised, 'ai_advised_topics' => $advised,
'llm_advised_topics' => $advised, 'llm_advised_topics' => $advised,
'unsubscribe_url' => $unsubUrl, '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) public function buildJournalVars($journal)
{ {
if (!$journal) return []; if (!$journal) return [];

View File

@@ -5,7 +5,7 @@ namespace app\common;
use think\Env; use think\Env;
/** /**
* 退订工具:生成/校验签名 token构造退订 URL。 * 退订工具:生成/校验签名 token构造退订 URL(支持 expert / user 两类受众)
* *
* .env 配置([unsubscribe] 段): * .env 配置([unsubscribe] 段):
* UNSUBSCRIBE_SECRET 用于签名的密钥(必须自行设置一个长随机字符串) * UNSUBSCRIBE_SECRET 用于签名的密钥(必须自行设置一个长随机字符串)
@@ -13,15 +13,22 @@ use think\Env;
* https://api.tmrjournals.com/api/Unsubscribe/index * https://api.tmrjournals.com/api/Unsubscribe/index
* *
* URL 格式: * URL 格式:
* {UNSUBSCRIBE_BASE_URL}?id={expert_id}&t={sha256(expert_id|email_lower|secret)} * {UNSUBSCRIBE_BASE_URL}?k={kind}&id={audience_id}&t={sha256(kind|id|email_lower|secret)}
*
* kind:
* expert → 外部专家库 t_expert.expert_id
* user → 系统内部用户 t_user.user_id编委 / 主编 / 青年编委等)
* *
* 设计要点: * 设计要点:
* - 不依赖 session纯 HMAC 风格签名,每个 expert 的 token 永久有效(直到密钥更换或邮箱变更)。 * - 不依赖 session纯 HMAC 风格签名token 永久有效(直到密钥更换或邮箱变更)。
* - 收到请求后服务端按 expert_id 查邮箱重新计算签名比对hash_equals 防时序攻击。 * - 收到请求后服务端按 kind+id 查邮箱重新计算签名比对hash_equals 防时序攻击。
* - 邮箱大小写敏感性:统一 strtolower 后再签名/校验。 * - 邮箱大小写敏感性:统一 strtolower 后再签名/校验。
*/ */
class UnsubscribeService class UnsubscribeService
{ {
const KIND_EXPERT = 'expert';
const KIND_USER = 'user';
/** /**
* 读取签名密钥;未配置时使用一个固定默认值(仅供本地测试,生产必须覆盖)。 * 读取签名密钥;未配置时使用一个固定默认值(仅供本地测试,生产必须覆盖)。
*/ */
@@ -29,7 +36,6 @@ class UnsubscribeService
{ {
$secret = trim((string)Env::get('unsubscribe.unsubscribe_secret', '')); $secret = trim((string)Env::get('unsubscribe.unsubscribe_secret', ''));
if ($secret === '') { if ($secret === '') {
// 兜底密钥;强烈建议在 .env 里覆盖
$secret = 'tmrjournals-default-unsubscribe-secret-change-me'; $secret = 'tmrjournals-default-unsubscribe-secret-change-me';
} }
return $secret; return $secret;
@@ -43,28 +49,39 @@ class UnsubscribeService
return rtrim(trim((string)Env::get('unsubscribe.unsubscribe_base_url', '')), '/'); return rtrim(trim((string)Env::get('unsubscribe.unsubscribe_base_url', '')), '/');
} }
/**
* 规范化 kind未知值降级为 expert保持向后兼容
*/
public static function normalizeKind($kind)
{
$kind = strtolower(trim((string)$kind));
return $kind === self::KIND_USER ? self::KIND_USER : self::KIND_EXPERT;
}
/** /**
* 生成签名 token。 * 生成签名 token。
*/ */
public static function buildToken($expertId, $email) public static function buildToken($kind, $audienceId, $email)
{ {
$expertId = intval($expertId); $kind = self::normalizeKind($kind);
$email = strtolower(trim((string)$email)); $audienceId = intval($audienceId);
return hash('sha256', $expertId . '|' . $email . '|' . self::getSecret()); $email = strtolower(trim((string)$email));
return hash('sha256', $kind . '|' . $audienceId . '|' . $email . '|' . self::getSecret());
} }
/** /**
* 校验签名 token恒定时间比较防时序攻击。 * 校验签名 token恒定时间比较防时序攻击。
*/ */
public static function verifyToken($expertId, $email, $token) public static function verifyToken($kind, $audienceId, $email, $token)
{ {
$expertId = intval($expertId); $kind = self::normalizeKind($kind);
$email = strtolower(trim((string)$email)); $audienceId = intval($audienceId);
$token = strtolower(trim((string)$token)); $email = strtolower(trim((string)$email));
if ($expertId <= 0 || $email === '' || $token === '') { $token = strtolower(trim((string)$token));
if ($audienceId <= 0 || $email === '' || $token === '') {
return false; return false;
} }
$expected = self::buildToken($expertId, $email); $expected = self::buildToken($kind, $audienceId, $email);
return hash_equals($expected, $token); return hash_equals($expected, $token);
} }
@@ -72,13 +89,17 @@ class UnsubscribeService
* 构造完整退订 URL用于邮件模板变量 {{unsubscribe_url}})。 * 构造完整退订 URL用于邮件模板变量 {{unsubscribe_url}})。
* BASE_URL 未配置或参数无效时返回空串。 * BASE_URL 未配置或参数无效时返回空串。
*/ */
public static function buildUrl($expertId, $email) public static function buildUrl($kind, $audienceId, $email)
{ {
$expertId = intval($expertId); $kind = self::normalizeKind($kind);
$email = trim((string)$email); $audienceId = intval($audienceId);
if ($expertId <= 0 || $email === '') return ''; $email = trim((string)$email);
if ($audienceId <= 0 || $email === '') return '';
$base = self::getBaseUrl(); $base = self::getBaseUrl();
if ($base === '') return ''; if ($base === '') return '';
return $base . '?id=' . $expertId . '&t=' . self::buildToken($expertId, $email); return $base
. '?k=' . $kind
. '&id=' . $audienceId
. '&t=' . self::buildToken($kind, $audienceId, $email);
} }
} }

View File

@@ -0,0 +1,11 @@
-- 推广邮件日志:增加内部受众 user_id 列
-- expert_type=5外部 expert 库expert_id 有值user_id=0
-- expert_type∈{1,2,3,4}(内部主编/编委/青年编委/作者user_id 有值expert_id=0
ALTER TABLE `t_promotion_email_log`
ADD COLUMN `user_id` INT NOT NULL DEFAULT 0
COMMENT '内部受众 user_idexpert_type∈{1..4} 时填,=5 时为 0'
AFTER `expert_id`;
ALTER TABLE `t_promotion_email_log`
ADD INDEX `idx_user_send_time` (`user_id`, `send_time`);