修改自动推广的相关任务
This commit is contained in:
@@ -437,6 +437,10 @@ class EmailClient extends Base
|
||||
break;
|
||||
}
|
||||
|
||||
$expert['application_link_yeditorial_board'] = \app\common\PromotionService::buildYboardApplyUrl(
|
||||
intval($expert['expert_id'] ?? 0),
|
||||
intval($journalId)
|
||||
);
|
||||
$expertVars = $this->buildExpertVars($expert);
|
||||
$vars = array_merge($journalVars, $expertVars);
|
||||
|
||||
@@ -1400,8 +1404,9 @@ class EmailClient extends Base
|
||||
if (intval($factory['state']) !== 0) {
|
||||
return jsonError('Factory is disabled');
|
||||
}
|
||||
if (intval($factory['expert_type']) !== 5) {
|
||||
return jsonError('Only expert_type=5 is supported currently');
|
||||
$expertType = intval($factory['expert_type']);
|
||||
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']);
|
||||
@@ -1461,19 +1466,30 @@ class EmailClient extends Base
|
||||
}
|
||||
}
|
||||
|
||||
$fields = $this->resolveFieldsByFetchIds($factory['fetch_ids']);
|
||||
if (empty($fields)) {
|
||||
return jsonError('No valid fields resolved from factory.fetch_ids');
|
||||
// 内部受众(type∈{1..4})忽略领域 / 国家筛选;外部 expert 库(type=5)正常解析
|
||||
if ($expertType === 5) {
|
||||
$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']);
|
||||
$targetCountryIds = trim((string)$factory['target_country_ids']);
|
||||
$dailyLimit = max(1, intval($factory['send_count']));
|
||||
$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)) {
|
||||
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 === '') {
|
||||
@@ -1513,7 +1529,8 @@ class EmailClient extends Base
|
||||
foreach ($experts as $expert) {
|
||||
$logs[] = [
|
||||
'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,
|
||||
'email_to' => $expert['email'],
|
||||
'subject' => '',
|
||||
@@ -2164,11 +2181,12 @@ class EmailClient extends Base
|
||||
* 每日自动生成推广任务(由 Linux crontab 调用)
|
||||
*
|
||||
* 逻辑:
|
||||
* 1. 查询所有 state=0 的任务工厂(当前仅处理 expert_type=5)
|
||||
* 1. 查询所有 state=0 的任务工厂(支持 expert_type=2 编委 / =5 expert 库;其他类型预留)
|
||||
* 2. JOIN journal 确认期刊有效(state=0, start_promotion=1)
|
||||
* 3. 按 factory_id + send_date 检查去重
|
||||
* 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
|
||||
*
|
||||
* crontab 示例(每天凌晨1点执行):
|
||||
@@ -2179,13 +2197,13 @@ class EmailClient extends Base
|
||||
set_time_limit(120);
|
||||
|
||||
$sendDate = date('Y-m-d', strtotime('+1 day'));
|
||||
$noRepeatDays = 30;
|
||||
$noRepeatDaysDefault = 30;
|
||||
|
||||
$factories = Db::name('promotion_factory')
|
||||
->alias('f')
|
||||
->join('t_journal j', 'j.journal_id = f.journal_id', 'inner')
|
||||
->where('f.state', 0)
|
||||
->where('f.expert_type', 5)
|
||||
->where('f.expert_type', 'in', [2, 3, 5])
|
||||
->where('j.state', 0)
|
||||
->where('f.start_promotion', 1)
|
||||
->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']);
|
||||
if (empty($fields)) {
|
||||
$errors[] = 'factory_id=' . $factoryId . ': no valid fields from fetch_ids';
|
||||
continue;
|
||||
$expertType = intval($factory['expert_type']);
|
||||
|
||||
// 内部受众(type∈{1..4}):默认 60 天频次(约稿场景);外部 expert 库(type=5)沿用 30 天
|
||||
$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']);
|
||||
$targetCountryIds = trim((string)$factory['target_country_ids']);
|
||||
$dailyLimit = max(1, intval($factory['send_count']));
|
||||
$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)) {
|
||||
$details[] = [
|
||||
'factory_id' => $factoryId,
|
||||
'journal_id' => $journalId,
|
||||
'title' => $factory['journal_title'],
|
||||
'status' => 'no_experts',
|
||||
'status' => 'no_audience',
|
||||
];
|
||||
continue;
|
||||
}
|
||||
@@ -2304,7 +2337,8 @@ class EmailClient extends Base
|
||||
foreach ($experts as $expert) {
|
||||
$logs[] = [
|
||||
'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,
|
||||
'email_to' => $expert['email'],
|
||||
'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 string $targetPartitions 国家分区,逗号分隔
|
||||
* @param string $targetCountryIds 单独指定的 country_id,逗号分隔
|
||||
* @param string $targetPartitions 国家分区,逗号分隔(仅 type=5 生效)
|
||||
* @param string $targetCountryIds 单独指定的 country_id,逗号分隔(仅 type=5 生效)
|
||||
* @param int $expertType 受众类型;默认 5 兼容老调用
|
||||
* @param int $journalId 期刊 ID(type∈{1..4} 必填)
|
||||
* @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))));
|
||||
|
||||
if (empty($fields)) {
|
||||
@@ -2511,6 +2668,84 @@ class EmailClient extends Base
|
||||
->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)
|
||||
{
|
||||
$file = $this->getInboxSseEventFile($accountId);
|
||||
@@ -2636,12 +2871,14 @@ class EmailClient extends Base
|
||||
|
||||
private function buildExpertVars($expert)
|
||||
{
|
||||
$email = (string)($expert['email'] ?? '');
|
||||
$unsubUrl = '';
|
||||
if (!empty($expert['expert_id']) && !empty($expert['email'])) {
|
||||
$unsubUrl = \app\common\UnsubscribeService::buildUrl(
|
||||
intval($expert['expert_id']),
|
||||
(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 [
|
||||
@@ -2650,6 +2887,7 @@ class EmailClient extends Base
|
||||
'affiliation' => $expert['affiliation'] ?? '',
|
||||
'field' => $expert['field'] ?? '',
|
||||
'unsubscribe_url' => $unsubUrl,
|
||||
'application_link_yeditorial_board' => $expert['application_link_yeditorial_board'] ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user