From 5645b87669e41f9ad2e0c12c36bbc95a9d983557 Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Mon, 27 Apr 2026 14:53:46 +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=20=20?= =?UTF-8?q?=E9=80=80=E8=AE=A2=E7=9B=B8=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 5 + application/api/controller/EmailClient.php | 26 +++- application/api/controller/ExpertManage.php | 54 +++++++ application/api/controller/Unsubscribe.php | 159 ++++++++++++++++++++ application/common/PromotionService.php | 31 ++++ application/common/UnsubscribeService.php | 84 +++++++++++ sql/add_unsubscribed_to_expert.sql | 9 ++ 7 files changed, 362 insertions(+), 6 deletions(-) create mode 100644 application/api/controller/Unsubscribe.php create mode 100644 application/common/UnsubscribeService.php create mode 100644 sql/add_unsubscribed_to_expert.sql diff --git a/.env b/.env index 2995b09..6a57f55 100644 --- a/.env +++ b/.env @@ -26,6 +26,11 @@ PROMOTION_LLM_TIMEOUT=30 PROMOTION_LLM_FALLBACK="We would like to cordially invite you to consider submitting a manuscript to {{journal_name}}." PROMOTION_LLM_ADVISED_FALLBACK="" +[unsubscribe] +UNSUBSCRIBE_SECRET="TMR Unsubscribe Secret create on 20260427" +UNSUBSCRIBE_BASE_URL=https://submission.tmrjournals.com/api/Unsubscribe/index + + [journal] ;官网服务器地址 base_url = http://journalapi.tmrjournals.com/public/index.php diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index ca39e8e..e01510f 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -411,10 +411,14 @@ class EmailClient extends Base } $ids = array_map('intval', explode(',', $expertIds)); - $experts = Db::name('expert')->where('expert_id', 'in', $ids)->where('state', 0)->select(); + $experts = Db::name('expert') + ->where('expert_id', 'in', $ids) + ->where('state', 0) + ->where('unsubscribed', 0) + ->select(); if (empty($experts)) { - return jsonError('No valid experts found (state must be 0)'); + return jsonError('No valid experts found (state must be 0 and not unsubscribed)'); } $sent = 0; @@ -2475,6 +2479,7 @@ class EmailClient extends Base $query = Db::name('expert')->alias('e') ->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner') ->where('e.state', 0) + ->where('e.unsubscribed', 0) ->where('ef.state', 0); $query->where(function ($q) use ($fields) { @@ -2631,11 +2636,20 @@ class EmailClient extends Base private function buildExpertVars($expert) { + $unsubUrl = ''; + if (!empty($expert['expert_id']) && !empty($expert['email'])) { + $unsubUrl = \app\common\UnsubscribeService::buildUrl( + intval($expert['expert_id']), + (string)$expert['email'] + ); + } + return [ - 'name' => $expert['name'] ?? '', - 'email' => $expert['email'] ?? '', - 'affiliation' => $expert['affiliation'] ?? '', - 'field' => $expert['field'] ?? '', + 'name' => $expert['name'] ?? '', + 'email' => $expert['email'] ?? '', + 'affiliation' => $expert['affiliation'] ?? '', + 'field' => $expert['field'] ?? '', + 'unsubscribe_url' => $unsubUrl, ]; } diff --git a/application/api/controller/ExpertManage.php b/application/api/controller/ExpertManage.php index 854c9da..bfa6828 100644 --- a/application/api/controller/ExpertManage.php +++ b/application/api/controller/ExpertManage.php @@ -653,4 +653,58 @@ class ExpertManage extends Base return jsonSuccess([]); } + + /** + * 后台手工将专家标记为退订 + * 参数: expert_id(必填) 或 expert_ids(逗号分隔批量) + */ + public function unsubscribe() + { + $data = $this->request->post(); + $idsRaw = trim((string)(isset($data['expert_ids']) ? $data['expert_ids'] : (isset($data['expert_id']) ? $data['expert_id'] : ''))); + if ($idsRaw === '') { + return jsonError('expert_id 或 expert_ids 必填'); + } + $ids = array_filter(array_map('intval', explode(',', $idsRaw))); + if (empty($ids)) { + return jsonError('expert_id 无效'); + } + + $affected = Db::name('expert') + ->where('expert_id', 'in', $ids) + ->where('unsubscribed', 0) + ->update(['unsubscribed' => 1]); + + return jsonSuccess([ + 'requested' => count($ids), + 'affected' => intval($affected), + ]); + } + + /** + * 后台手工恢复专家订阅状态 + * 参数: expert_id(必填) 或 expert_ids(逗号分隔批量) + */ + public function resubscribe() + { + $data = $this->request->post(); + $idsRaw = trim((string)(isset($data['expert_ids']) ? $data['expert_ids'] : (isset($data['expert_id']) ? $data['expert_id'] : ''))); + if ($idsRaw === '') { + return jsonError('expert_id 或 expert_ids 必填'); + } + $ids = array_filter(array_map('intval', explode(',', $idsRaw))); + if (empty($ids)) { + return jsonError('expert_id 无效'); + } + + $affected = Db::name('expert') + ->where('expert_id', 'in', $ids) + ->where('unsubscribed', 1) + ->update(['unsubscribed' => 0]); + + return jsonSuccess([ + 'requested' => count($ids), + 'affected' => intval($affected), + ]); + } } diff --git a/application/api/controller/Unsubscribe.php b/application/api/controller/Unsubscribe.php new file mode 100644 index 0000000..59ec3c1 --- /dev/null +++ b/application/api/controller/Unsubscribe.php @@ -0,0 +1,159 @@ +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) { + return $this->html($this->pageInvalid(), 404); + } + if (!UnsubscribeService::verifyToken($id, (string)$expert['email'], $token)) { + return $this->html($this->pageInvalid(), 403); + } + if (!empty($expert['unsubscribed'])) { + return $this->html($this->pageAlreadyDone((string)$expert['email'])); + } + + return $this->html($this->pageConfirm( + $id, + $token, + (string)$expert['email'], + (string)($expert['name'] ?? '') + )); + } + + /** + * 真正执行退订(POST 推荐;GET 也允许,以兼容部分邮件客户端禁止表单提交) + */ + public function confirm() + { + $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) { + return $this->html($this->pageInvalid(), 404); + } + if (!UnsubscribeService::verifyToken($id, (string)$expert['email'], $token)) { + return $this->html($this->pageInvalid(), 403); + } + + if (empty($expert['unsubscribed'])) { + Db::name('expert')->where('expert_id', $id)->update([ + 'unsubscribed' => 1, + ]); + } + + return $this->html($this->pageSuccess((string)$expert['email'])); + } + + // ==================== HTML 页面 ==================== + + private function html($html, $status = 200) + { + $resp = Response::create($html, 'html', $status); + $resp->header('Content-Type', 'text/html; charset=utf-8'); + return $resp; + } + + private function pageShell($title, $bodyHtml) + { + $titleSafe = htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); + $css = " + body{margin:0;padding:0;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:#f5f7fa;color:#1f2937;} + .wrap{max-width:520px;margin:64px auto;padding:32px;background:#fff;border-radius:12px;box-shadow:0 2px 16px rgba(0,0,0,.06);} + h1{font-size:22px;margin:0 0 16px;color:#111827;} + p{font-size:15px;line-height:1.6;margin:0 0 16px;color:#374151;} + .email{font-weight:600;color:#111827;word-break:break-all;} + .btn{display:inline-block;padding:10px 24px;border-radius:6px;border:0;cursor:pointer;font-size:15px;text-decoration:none;} + .btn-primary{background:#dc2626;color:#fff;} + .btn-primary:hover{background:#b91c1c;} + .btn-secondary{background:#e5e7eb;color:#374151;margin-left:8px;} + .muted{color:#6b7280;font-size:13px;margin-top:24px;} + .ok{color:#16a34a;font-weight:600;} + .warn{color:#d97706;font-weight:600;} + .err{color:#dc2626;font-weight:600;} + "; + return '' + . '' + . '' + . '' . $titleSafe . '' + . '' + . '
' . $bodyHtml . '
'; + } + + private function pageConfirm($id, $token, $email, $name) + { + $idSafe = intval($id); + $tokenSafe = htmlspecialchars($token, ENT_QUOTES, 'UTF-8'); + $emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); + $nameSafe = htmlspecialchars($name !== '' ? $name : $email, ENT_QUOTES, 'UTF-8'); + + $body = '

Confirm unsubscribe

' + . '

Hi ' . $nameSafe . ',

' + . '

You are about to unsubscribe ' . $emailSafe + . ' from all promotion and invitation emails sent by TMR Journals. ' + . 'After unsubscribing you will no longer receive marketing emails from us.

' + . '
' + . '' + . '' + . '' + . 'Cancel' + . '
' + . '

If you didn\'t expect this email, you can safely close this page.

'; + + return $this->pageShell('Confirm unsubscribe - TMR Journals', $body); + } + + private function pageSuccess($email) + { + $emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); + $body = '

You have been unsubscribed

' + . '

' . $emailSafe . ' will no longer receive promotion or invitation emails from TMR Journals.

' + . '

If this was a mistake, please contact us and we will be happy to resubscribe you.

' + . '

Thank you for your past interest in our journals.

'; + return $this->pageShell('Unsubscribed - TMR Journals', $body); + } + + private function pageAlreadyDone($email) + { + $emailSafe = htmlspecialchars($email, ENT_QUOTES, 'UTF-8'); + $body = '

Already unsubscribed

' + . '

' . $emailSafe . ' is already unsubscribed from our promotion emails.

' + . '

No further action is needed.

'; + return $this->pageShell('Already unsubscribed - TMR Journals', $body); + } + + private function pageInvalid() + { + $body = '

Invalid or expired link

' + . '

This unsubscribe link is invalid or has expired.

' + . '

Please use the most recent unsubscribe link in our emails, or contact us for help.

'; + return $this->pageShell('Invalid link - TMR Journals', $body); + } +} diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php index b3d3b03..f938241 100644 --- a/application/common/PromotionService.php +++ b/application/common/PromotionService.php @@ -74,6 +74,18 @@ class PromotionService 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']; + } + $account = $this->pickSmtpAccountForTask($task); if (!$account) { $this->enqueueNextEmail($taskId, 600); @@ -285,6 +297,15 @@ class PromotionService $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']) @@ -792,6 +813,15 @@ class PromotionService { $llm = $expert['llm_description'] ?? ''; $advised = $expert['ai_advised_topics'] ?? ''; + + $unsubUrl = ''; + if (!empty($expert['expert_id']) && !empty($expert['email'])) { + $unsubUrl = \app\common\UnsubscribeService::buildUrl( + intval($expert['expert_id']), + (string)$expert['email'] + ); + } + return [ 'expert_title' => "Ph.D", 'expert_name' => $expert['name'] ?? '', @@ -803,6 +833,7 @@ class PromotionService 'ai_content_analysis' => $llm, 'ai_advised_topics' => $advised, 'llm_advised_topics' => $advised, + 'unsubscribe_url' => $unsubUrl, ]; } diff --git a/application/common/UnsubscribeService.php b/application/common/UnsubscribeService.php new file mode 100644 index 0000000..036f8b8 --- /dev/null +++ b/application/common/UnsubscribeService.php @@ -0,0 +1,84 @@ +