From 02242b1f08f51eccd5526648f8ae9419ff0819b0 Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Wed, 29 Apr 2026 15:07:56 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=87=AA=E5=8A=A8=E6=8E=A8?= =?UTF-8?q?=E5=B9=BF=E7=9A=84=E7=9B=B8=E5=85=B3=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 2 + application/api/controller/EmailClient.php | 310 +++++++++-- application/api/controller/ExpertFinder.php | 2 +- application/api/controller/Ucenter.php | 8 + application/api/controller/Unsubscribe.php | 69 ++- application/api/job/PromotionPrepareEmail.php | 8 +- application/common/PromotionService.php | 511 ++++++++++++------ application/common/UnsubscribeService.php | 61 ++- sql/add_user_id_to_promotion_email_log.sql | 11 + 9 files changed, 738 insertions(+), 244 deletions(-) create mode 100644 sql/add_user_id_to_promotion_email_log.sql diff --git a/.env b/.env index 6a57f55..ad8120e 100644 --- a/.env +++ b/.env @@ -30,6 +30,8 @@ PROMOTION_LLM_ADVISED_FALLBACK="" UNSUBSCRIBE_SECRET="TMR Unsubscribe Secret create on 20260427" UNSUBSCRIBE_BASE_URL=https://submission.tmrjournals.com/api/Unsubscribe/index +[yboard] +APPLY_URL="https://submission.tmrjournals.com/youthBoardRegister" [journal] ;官网服务器地址 diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index e01510f..18ad907 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -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'] ?? '', ]; } diff --git a/application/api/controller/ExpertFinder.php b/application/api/controller/ExpertFinder.php index ee8e5aa..1998454 100644 --- a/application/api/controller/ExpertFinder.php +++ b/application/api/controller/ExpertFinder.php @@ -298,7 +298,7 @@ class ExpertFinder extends Base */ 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'); $minYear = intval($this->request->param('min_year', date('Y') - 3)); diff --git a/application/api/controller/Ucenter.php b/application/api/controller/Ucenter.php index ef05e41..37b6dbf 100644 --- a/application/api/controller/Ucenter.php +++ b/application/api/controller/Ucenter.php @@ -376,6 +376,14 @@ class Ucenter extends Base{ if(isset($data['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']):''; $this->user_obj->where(['user_id'=>$data['user_id']])->update($update); $updata1=[ diff --git a/application/api/controller/Unsubscribe.php b/application/api/controller/Unsubscribe.php index 59ec3c1..fda20c0 100644 --- a/application/api/controller/Unsubscribe.php +++ b/application/api/controller/Unsubscribe.php @@ -8,14 +8,17 @@ use think\Response; use app\common\UnsubscribeService; /** - * 推广邮件退订入口(公开访问,无需登录) + * 推广 / 约稿邮件退订入口(公开访问,无需登录) * * 路由: - * GET /api/Unsubscribe/index?id=&t= 展示确认页 - * POST /api/Unsubscribe/confirm?id=&t= 执行退订 + * GET /api/Unsubscribe/index?k=&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 @@ -25,51 +28,77 @@ class Unsubscribe extends Controller */ public function index() { + $kind = UnsubscribeService::normalizeKind($this->request->param('k', UnsubscribeService::KIND_EXPERT)); $id = intval($this->request->param('id', 0)); $token = trim((string)$this->request->param('t', '')); - $expert = $id > 0 ? Db::name('expert')->where('expert_id', $id)->find() : null; - if (!$expert) { + $audience = $this->loadAudience($kind, $id); + if (!$audience) { 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); } - if (!empty($expert['unsubscribed'])) { - return $this->html($this->pageAlreadyDone((string)$expert['email'])); + if (!empty($audience['unsubscribed'])) { + return $this->html($this->pageAlreadyDone((string)$audience['email'])); } return $this->html($this->pageConfirm( + $kind, $id, $token, - (string)$expert['email'], - (string)($expert['name'] ?? '') + (string)$audience['email'], + (string)$audience['name'] )); } /** - * 真正执行退订(POST 推荐;GET 也允许,以兼容部分邮件客户端禁止表单提交) + * 真正执行退订(POST 推荐;GET 也允许) */ public function confirm() { + $kind = UnsubscribeService::normalizeKind($this->request->param('k', UnsubscribeService::KIND_EXPERT)); $id = intval($this->request->param('id', 0)); $token = trim((string)$this->request->param('t', '')); - $expert = $id > 0 ? Db::name('expert')->where('expert_id', $id)->find() : null; - if (!$expert) { + $audience = $this->loadAudience($kind, $id); + if (!$audience) { 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); } - if (empty($expert['unsubscribed'])) { - Db::name('expert')->where('expert_id', $id)->update([ + if (empty($audience['unsubscribed'])) { + $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, ]); } - 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 页面 ==================== @@ -107,8 +136,9 @@ class Unsubscribe extends Controller . '
' . $bodyHtml . '
'; } - 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); $tokenSafe = htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); $emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); @@ -120,6 +150,7 @@ class Unsubscribe extends Controller . ' from all promotion and invitation emails sent by TMR Journals. ' . 'After unsubscribing you will no longer receive marketing emails from us.

' . '
' + . '' . '' . '' . '' diff --git a/application/api/job/PromotionPrepareEmail.php b/application/api/job/PromotionPrepareEmail.php index 5de5d91..65c8d89 100644 --- a/application/api/job/PromotionPrepareEmail.php +++ b/application/api/job/PromotionPrepareEmail.php @@ -24,16 +24,12 @@ class PromotionPrepareEmail { $logId = intval(isset($data['log_id']) ? $data['log_id'] : 0); $service = new PromotionService(); - - $service->log('[PromotionPrepareEmail] m11ylog=' . $logId); - - if (!$logId) { $job->delete(); return; } - $result = $service->prepareSingleEmail($logId); - $service->log('[PromotionPrepareEm111ail] m11ylog=' . $logId); + $service->prepareSingleEmail($logId); + $job->delete(); // // try { // $result = $service->prepareSingleEmail($logId); diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php index f938241..a0fdd5d 100644 --- a/application/common/PromotionService.php +++ b/application/common/PromotionService.php @@ -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; + // 内部受众:跳过 LLM;representative_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 []; diff --git a/application/common/UnsubscribeService.php b/application/common/UnsubscribeService.php index 036f8b8..0120cbf 100644 --- a/application/common/UnsubscribeService.php +++ b/application/common/UnsubscribeService.php @@ -5,7 +5,7 @@ namespace app\common; use think\Env; /** - * 退订工具:生成/校验签名 token,构造退订 URL。 + * 退订工具:生成/校验签名 token,构造退订 URL(支持 expert / user 两类受众)。 * * .env 配置([unsubscribe] 段): * UNSUBSCRIBE_SECRET 用于签名的密钥(必须自行设置一个长随机字符串) @@ -13,15 +13,22 @@ use think\Env; * https://api.tmrjournals.com/api/Unsubscribe/index * * 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 永久有效(直到密钥更换或邮箱变更)。 - * - 收到请求后服务端按 expert_id 查邮箱,重新计算签名比对,hash_equals 防时序攻击。 + * - 不依赖 session:纯 HMAC 风格签名,token 永久有效(直到密钥更换或邮箱变更)。 + * - 收到请求后服务端按 kind+id 查邮箱,重新计算签名比对,hash_equals 防时序攻击。 * - 邮箱大小写敏感性:统一 strtolower 后再签名/校验。 */ class UnsubscribeService { + const KIND_EXPERT = 'expert'; + const KIND_USER = 'user'; + /** * 读取签名密钥;未配置时使用一个固定默认值(仅供本地测试,生产必须覆盖)。 */ @@ -29,7 +36,6 @@ class UnsubscribeService { $secret = trim((string)Env::get('unsubscribe.unsubscribe_secret', '')); if ($secret === '') { - // 兜底密钥;强烈建议在 .env 里覆盖 $secret = 'tmrjournals-default-unsubscribe-secret-change-me'; } return $secret; @@ -43,28 +49,39 @@ class UnsubscribeService 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。 */ - public static function buildToken($expertId, $email) + public static function buildToken($kind, $audienceId, $email) { - $expertId = intval($expertId); - $email = strtolower(trim((string)$email)); - return hash('sha256', $expertId . '|' . $email . '|' . self::getSecret()); + $kind = self::normalizeKind($kind); + $audienceId = intval($audienceId); + $email = strtolower(trim((string)$email)); + return hash('sha256', $kind . '|' . $audienceId . '|' . $email . '|' . self::getSecret()); } /** * 校验签名 token;恒定时间比较,防时序攻击。 */ - public static function verifyToken($expertId, $email, $token) + public static function verifyToken($kind, $audienceId, $email, $token) { - $expertId = intval($expertId); - $email = strtolower(trim((string)$email)); - $token = strtolower(trim((string)$token)); - if ($expertId <= 0 || $email === '' || $token === '') { + $kind = self::normalizeKind($kind); + $audienceId = intval($audienceId); + $email = strtolower(trim((string)$email)); + $token = strtolower(trim((string)$token)); + if ($audienceId <= 0 || $email === '' || $token === '') { return false; } - $expected = self::buildToken($expertId, $email); + $expected = self::buildToken($kind, $audienceId, $email); return hash_equals($expected, $token); } @@ -72,13 +89,17 @@ class UnsubscribeService * 构造完整退订 URL(用于邮件模板变量 {{unsubscribe_url}})。 * BASE_URL 未配置或参数无效时返回空串。 */ - public static function buildUrl($expertId, $email) + public static function buildUrl($kind, $audienceId, $email) { - $expertId = intval($expertId); - $email = trim((string)$email); - if ($expertId <= 0 || $email === '') return ''; + $kind = self::normalizeKind($kind); + $audienceId = intval($audienceId); + $email = trim((string)$email); + if ($audienceId <= 0 || $email === '') return ''; $base = self::getBaseUrl(); if ($base === '') return ''; - return $base . '?id=' . $expertId . '&t=' . self::buildToken($expertId, $email); + return $base + . '?k=' . $kind + . '&id=' . $audienceId + . '&t=' . self::buildToken($kind, $audienceId, $email); } } diff --git a/sql/add_user_id_to_promotion_email_log.sql b/sql/add_user_id_to_promotion_email_log.sql new file mode 100644 index 0000000..12329f3 --- /dev/null +++ b/sql/add_user_id_to_promotion_email_log.sql @@ -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_id;expert_type∈{1..4} 时填,=5 时为 0' + AFTER `expert_id`; + +ALTER TABLE `t_promotion_email_log` + ADD INDEX `idx_user_send_time` (`user_id`, `send_time`);