From 22947a56a42485e2804a1721eadcba1e22dc6e4c Mon Sep 17 00:00:00 2001 From: wangjinlei <751475802@qq.com> Date: Fri, 27 Mar 2026 17:57:25 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8E=A8=E5=B9=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- application/api/controller/EmailClient.php | 453 +++++++++++++++++--- application/api/controller/ExpertFinder.php | 86 ++-- application/api/controller/ExpertManage.php | 170 +++++++- application/api/controller/Preaccept.php | 24 +- application/common/ExpertFinderService.php | 37 +- 5 files changed, 625 insertions(+), 145 deletions(-) diff --git a/application/api/controller/EmailClient.php b/application/api/controller/EmailClient.php index c0fe36f..cec596a 100644 --- a/application/api/controller/EmailClient.php +++ b/application/api/controller/EmailClient.php @@ -31,13 +31,21 @@ class EmailClient extends Base ->select(); foreach ($list as &$item) { - $item['smtp_password'] = '******'; +// $item['smtp_password'] = '******'; $item['remaining_today'] = max(0, $item['daily_limit'] - $item['today_sent']); } return jsonSuccess($list); } + public function getAccountsAll(){ + $list = Db::name('journal_email') + ->where("state",0) + ->order('state asc, j_email_id asc') + ->select(); + return jsonSuccess($list); + } + /** * Add SMTP account */ @@ -546,8 +554,33 @@ class EmailClient extends Base } $report = []; + $accountUpdates = []; foreach ($accounts as $account) { - $report[] = $this->doSyncAccount($account); + $one = $this->doSyncAccount($account); + $report[] = $one; + + $aid = intval($account['j_email_id']); + if (!isset($accountUpdates[$aid])) { + $accountUpdates[$aid] = [ + 'synced' => 0, + 'bounced' => 0, + 'journal_id' => intval($account['journal_id']), + ]; + } + $accountUpdates[$aid]['synced'] += intval(isset($one['synced']) ? $one['synced'] : 0); + $accountUpdates[$aid]['bounced'] += intval(isset($one['bounced']) ? $one['bounced'] : 0); + } + + // 发布 SSE 事件:按 j_email_id 维度通知;前端收到后自行刷新列表 + foreach ($accountUpdates as $aid => $sum) { + $this->publishInboxUpdatedEvent($aid, [ + 'type' => 'inbox_updated', + 'j_email_id' => intval($aid), + 'journal_id' => intval($sum['journal_id']), + 'synced' => intval($sum['synced']), + 'bounced' => intval($sum['bounced']), + 'time' => date('Y-m-d H:i:s'), + ]); } return jsonSuccess(['report' => $report]); @@ -628,7 +661,7 @@ class EmailClient extends Base 'content_text' => $body['text'], 'email_date' => $emailDate, 'has_attachment' => $hasAttachment ? 1 : 0, - 'is_read' => 0, + 'is_read' => mb_substr($subject, 0, 512)=="failure notice"?1:0, 'is_starred' => 0, 'is_bounce' => $isBounce ? 1 : 0, 'bounce_email' => mb_substr($bounceEmail, 0, 128), @@ -668,6 +701,8 @@ class EmailClient extends Base ]; } + + // ==================== Inbox - Read ==================== /** @@ -739,6 +774,7 @@ class EmailClient extends Base * Get single email detail */ public function getEmailDetail() + { $inboxId = intval($this->request->param('inbox_id', 0)); if (!$inboxId) { @@ -785,6 +821,126 @@ class EmailClient extends Base ]); } + /** + * SSE: 订阅收件箱更新事件(按 j_email_id 维度)。 + * 前端建议按 j_email_id 建立连接,收到 inbox_updated 后自行请求 getInboxList/getUnreadCount。 + * + * URL: /api/email_client/inboxSse?j_email_id=3 + */ + public function inboxSse() + { + $accountId = intval($this->request->param('j_email_id', 0)); + + if (!$accountId) { + return jsonError('j_email_id is required'); + } + + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + + while (ob_get_level() > 0) { + ob_end_flush(); + } + @ob_implicit_flush(1); + @set_time_limit(0); + + $eventFile = $this->getInboxSseEventFile($accountId); + $lastMtime = is_file($eventFile) ? filemtime($eventFile) : 0;//记录初始时间 + + echo "retry: 3000\n"; + echo "event: connected\n"; + echo "data: " . json_encode([ + 'msg' => 'SSE connected', + 'j_email_id' => $accountId, + 'time' => date('Y-m-d H:i:s'), + ], JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); + + // 单连接保持 1.5 分钟,随后由前端自动重连 + $maxLoop = 90; + for ($i = 0; $i < $maxLoop; $i++) { + if (connection_aborted()) { + break; + } + + clearstatcache(true, $eventFile); + $mtime = is_file($eventFile) ? filemtime($eventFile) : 0;//新时间 + if ($mtime > $lastMtime) { + $lastMtime = $mtime; + $content = @file_get_contents($eventFile); + $payload = json_decode($content, true); + if (!is_array($payload)) { + $payload = [ + 'type' => 'inbox_updated', + 'j_email_id' => $accountId, + 'time' => date('Y-m-d H:i:s'), + ]; + } + echo "event: inbox_updated\n"; + echo "id: " . $mtime . "\n"; + echo "data: " . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); + } else { + // 心跳,避免中间层断开空闲 SSE 连接 + echo ": ping " . time() . "\n\n"; + flush(); + } + + sleep(1); + } + + echo "event: disconnected\n"; + exit; + } + + /** + * SSE demo: fixed message stream for Vue EventSource testing. + * URL: /api/email_client/sseDemo + */ + public function sseDemo() + { + header('Content-Type: text/event-stream'); + header('Cache-Control: no-cache'); + header('Connection: keep-alive'); + header('X-Accel-Buffering: no'); + + while (ob_get_level() > 0) { + ob_end_flush(); + } + @ob_implicit_flush(1); + + echo "retry: 3000\n"; + echo "event: connected\n"; + echo "data: " . json_encode([ + 'msg' => 'SSE connected', + 'time' => date('Y-m-d H:i:s'), + ], JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); + + for ($i = 1; $i <= 5; $i++) { + if (connection_aborted()) { + break; + } + + echo "event: message\n"; + echo "id: " . $i . "\n"; + echo "data: " . json_encode([ + 'msg' => 'This is fixed SSE demo content', + 'index' => $i, + 'time' => date('Y-m-d H:i:s'), + ], JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); + sleep(2); + } + + echo "event: done\n"; + echo "data: " . json_encode(['msg' => 'SSE demo finished'], JSON_UNESCAPED_UNICODE) . "\n\n"; + flush(); + exit; + } + // ==================== Inbox - Actions ==================== /** @@ -1266,54 +1422,41 @@ class EmailClient extends Base ->where('state', 0) ->select(); } else { - // 一般情况:不传 expert_ids,根据期刊实际领域自动选择专家 + // 不传 expert_ids:根据期刊选定的推广领域(journal_promotion_field → expert_fetch)自动选择专家 $journal = Db::name('journal')->where('journal_id', $journalId)->find(); - if (!$journal || empty($journal['issn'])) { - return jsonError('Journal or ISSN not found'); + if (!$journal) { + return jsonError('Journal not found'); } - // 期刊绑定的领域(major_title),与 dailyFetchAll 保持一致 - $majors = Db::name('major_to_journal') - ->alias('mtj') - ->join('t_major m', 'm.major_id = mtj.major_id', 'left') - ->where('mtj.journal_issn', $journal['issn']) - ->where('mtj.mtj_state', 0) - ->where('m.major_state', 0) - ->column('m.major_title'); - - $majors = array_unique(array_filter($majors)); + $promotionFields = Db::name('journal_promotion_field') + ->alias('jpf') + ->join('t_expert_fetch ef_fetch', 'ef_fetch.expert_fetch_id = jpf.expert_fetch_id', 'inner') + ->where('jpf.journal_id', $journalId) + ->where('jpf.state', 0) + ->where('ef_fetch.state', 0) + ->column('ef_fetch.field'); + $promotionFields = array_unique(array_filter(array_map('trim', $promotionFields))); $query = Db::name('expert')->alias('e') - ->join('t_expert_field ef', 'e.expert_id = ef.expert_id') + ->join('t_expert_field ef', 'e.expert_id = ef.expert_id', 'inner') ->where('e.state', 0) ->where('ef.state', 0); - // 领域条件: - // 1) 若前端显式传了 field/major_id,则优先按传入条件过滤 - // 2) 否则,按期刊绑定领域(major_title)在 ef.field 中模糊匹配 - $query->where(function ($q) use ($field, $majorId, $majors) { - if ($field !== '') { - $q->where('ef.field', 'like', '%' . $field . '%'); - } elseif ($majorId > 0) { - $q->where('ef.major_id', $majorId); - } elseif (!empty($majors)) { - $q->where(function ($qq) use ($majors) { - foreach ($majors as $idx => $title) { - $title = trim($title); - if ($title === '') { - continue; - } - if ($idx === 0) { - $qq->where('ef.field', 'like', '%' . $title . '%'); - } else { - $qq->whereOr('ef.field', 'like', '%' . $title . '%'); - } + // 前端显式传了 field 则优先,否则走期刊选定的推广领域 + if ($field !== '') { + $query->where('ef.field', 'like', '%' . $field . '%'); + } elseif (!empty($promotionFields)) { + $query->where(function ($q) use ($promotionFields) { + foreach ($promotionFields as $idx => $fieldName) { + if ($idx === 0) { + $q->where('ef.field', 'like', '%' . $fieldName . '%'); + } else { + $q->whereOr('ef.field', 'like', '%' . $fieldName . '%'); } - }); - } - }); + } + }); + } - // 不频繁发送:在 SQL 中直接使用 ltime + no_repeat_days 过滤 if ($noRepeatDays > 0) { $cutoff = time() - ($noRepeatDays * 86400); $query->where(function ($q) use ($cutoff) { @@ -1321,7 +1464,6 @@ class EmailClient extends Base }); } - // 去重同一个专家 $experts = $query ->field('e.*') ->group('e.expert_id') @@ -1467,6 +1609,125 @@ class EmailClient extends Base return jsonSuccess($re); } + // ==================== Journal Promotion Field Mapping ==================== + + /** + * 获取某期刊已选的推广领域 + * 参数: journal_id + */ + public function getJournalPromotionFields() + { + $journalId = intval($this->request->param('journal_id', 0)); + if (!$journalId) { + return jsonError('journal_id is required'); + } + + $list = Db::name('journal_promotion_field') + ->alias('jpf') + ->join('t_expert_fetch ef', 'ef.expert_fetch_id = jpf.expert_fetch_id', 'left') + ->where('jpf.journal_id', $journalId) + ->where('jpf.state', 0) + ->field('jpf.jpf_id, jpf.expert_fetch_id, ef.field, ef.source, ef.state as fetch_state, jpf.ctime') + ->order('jpf.jpf_id asc') + ->select(); + + foreach ($list as &$item) { + $item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : ''; + } + + return jsonSuccess($list); + } + + /** + * 设置/更新期刊的推广领域(全量覆盖) + * 参数: journal_id, fetch_ids (逗号分隔的 expert_fetch_id 列表,如 "1,3,5") + */ + public function setJournalPromotionFields() + { + $data = $this->request->post(); + $journalId = intval(isset($data['journal_id']) ? $data['journal_id'] : 0); + $fetchIds = trim(isset($data['fetch_ids']) ? $data['fetch_ids'] : ''); + + if (!$journalId) { + return jsonError('journal_id is required'); + } + + $newIds = []; + if ($fetchIds !== '') { + $newIds = array_unique(array_map('intval', explode(',', $fetchIds))); + $newIds = array_filter($newIds, function ($v) { return $v > 0; }); + } + + $currentIds = Db::name('journal_promotion_field') + ->where('journal_id', $journalId) + ->where('state', 0) + ->column('expert_fetch_id'); + + $toAdd = array_diff($newIds, $currentIds); + $toRemove = array_diff($currentIds, $newIds); + + if (!empty($toRemove)) { + Db::name('journal_promotion_field') + ->where('journal_id', $journalId) + ->where('expert_fetch_id', 'in', $toRemove) + ->where('state', 0) + ->update(['state' => 1]); + } + + $now = time(); + foreach ($toAdd as $fetchId) { + $exists = Db::name('journal_promotion_field') + ->where('journal_id', $journalId) + ->where('expert_fetch_id', $fetchId) + ->find(); + if ($exists) { + if ($exists['state'] == 1) { + Db::name('journal_promotion_field') + ->where('jpf_id', $exists['jpf_id']) + ->update(['state' => 0]); + } + } else { + Db::name('journal_promotion_field')->insert([ + 'journal_id' => $journalId, + 'expert_fetch_id' => $fetchId, + 'state' => 0, + 'ctime' => $now, + ]); + } + } + + $finalList = Db::name('journal_promotion_field') + ->alias('jpf') + ->join('t_expert_fetch ef', 'ef.expert_fetch_id = jpf.expert_fetch_id', 'left') + ->where('jpf.journal_id', $journalId) + ->where('jpf.state', 0) + ->field('jpf.jpf_id, jpf.expert_fetch_id, ef.field') + ->select(); + + return jsonSuccess([ + 'added' => count($toAdd), + 'removed' => count($toRemove), + 'current' => $finalList, + ]); + } + + /** + * 获取所有可选的抓取领域(供期刊选择时的下拉列表) + * 返回 t_expert_fetch 中 state=0 的所有记录 + */ + public function getAvailableFields() + { + $list = Db::name('expert_fetch') + ->where('state', 0) + ->order('field asc') + ->select(); + + foreach ($list as &$item) { + $item['last_time_text'] = $item['last_time'] ? date('Y-m-d H:i:s', $item['last_time']) : ''; + } + + return jsonSuccess($list); + } /** * 为单个任务预生成邮件(可手动或测试用) @@ -1758,6 +2019,53 @@ class EmailClient extends Base ]); } + + public function getTaskLogDetail(){ + $data = $this->request->post(); + $rule = new Validate([ + "log_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + $info = Db::name("promotion_email_log")->where("log_id",$data['log_id'])->find(); + $expert_info = Db::name("expert")->where("expert_id",$info['expert_id'])->find(); + $expert_info['fields'] = Db::name("expert_field")->where("expert_id",$info['expert_id'])->where("state",0)->select(); + + return jsonSuccess(['log'=>$info,'expert'=>$expert_info]); + + } + + public function cancelTaskLog(){ + $data = $this->request->post(); + $rule = new Validate([ + "log_id"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + Db::name("promotion_email_log")->where("log_id",$data['log_id'])->update(['state'=>4]); + return jsonSuccess(); + } + + public function updateTaskLog(){ + $data = $this->request->post(); + $rule = new Validate([ + "log_id"=>"require", + "subject_prepared"=>"require", + "body_prepared"=>"require" + ]); + if(!$rule->check($data)){ + return jsonError($rule->getError()); + } + + $update['subject_prepared']=$data['subject_prepared']; + $update['body_prepared']=$data['body_prepared']; + Db::name("promotion_email_log")->where("log_id",$data['log_id'])->update($update); + $info = Db::name("promotion_email_log")->where("log_id",$data['log_id'])->find(); + return jsonSuccess(['log'=>$info]); + } + /** * Cron: check bounce emails and update promotion logs accordingly * Should be called periodically after syncInbox runs. @@ -2014,23 +2322,23 @@ class EmailClient extends Base } /** - * 根据期刊绑定的领域自动筛选合适的专家 + * 根据期刊选定的推广领域(journal_promotion_field → expert_fetch)筛选合适的专家 */ private function findEligibleExperts($journal, $noRepeatDays, $limit) { - $issn = trim($journal['issn'] ?? ''); + $journalId = intval($journal['journal_id']); - $majors = []; - if (!empty($issn)) { - $majors = Db::name('major_to_journal') - ->alias('mtj') - ->join('t_major m', 'm.major_id = mtj.major_id', 'left') - ->where('mtj.journal_issn', $issn) - ->where('mtj.mtj_state', 0) - ->where('m.major_state', 0) - ->where('m.pid', '<>', 0) - ->column('m.major_title'); - $majors = array_unique(array_filter($majors)); + $fields = Db::name('journal_promotion_field') + ->alias('jpf') + ->join('t_expert_fetch ef_fetch', 'ef_fetch.expert_fetch_id = jpf.expert_fetch_id', 'inner') + ->where('jpf.journal_id', $journalId) + ->where('jpf.state', 0) + ->where('ef_fetch.state', 0) + ->column('ef_fetch.field'); + $fields = array_unique(array_filter(array_map('trim', $fields))); + + if (empty($fields)) { + return []; } $query = Db::name('expert')->alias('e') @@ -2038,19 +2346,15 @@ class EmailClient extends Base ->where('e.state', 0) ->where('ef.state', 0); - if (!empty($majors)) { - $query->where(function ($q) use ($majors) { - foreach ($majors as $idx => $title) { - $title = trim($title); - if ($title === '') continue; - if ($idx === 0) { - $q->where('ef.field', 'like', '%' . $title . '%'); - } else { - $q->whereOr('ef.field', 'like', '%' . $title . '%'); - } + $query->where(function ($q) use ($fields) { + foreach ($fields as $idx => $fieldName) { + if ($idx === 0) { + $q->where('ef.field', 'like', '%' . $fieldName . '%'); + } else { + $q->whereOr('ef.field', 'like', '%' . $fieldName . '%'); } - }); - } + } + }); if ($noRepeatDays > 0) { $cutoff = time() - ($noRepeatDays * 86400); @@ -2066,6 +2370,21 @@ class EmailClient extends Base ->select(); } + private function publishInboxUpdatedEvent($accountId, $payload) + { + $file = $this->getInboxSseEventFile($accountId); + $dir = dirname($file); + if (!is_dir($dir)) { + @mkdir($dir, 0777, true); + } + @file_put_contents($file, json_encode($payload, JSON_UNESCAPED_UNICODE)); + } + + private function getInboxSseEventFile($accountId) + { + return ROOT_PATH . 'runtime' . DS . 'sse' . DS . 'inbox_email_' . intval($accountId) . '.json'; + } + // ==================== Internal Methods ==================== /** diff --git a/application/api/controller/ExpertFinder.php b/application/api/controller/ExpertFinder.php index e555faf..fb37254 100644 --- a/application/api/controller/ExpertFinder.php +++ b/application/api/controller/ExpertFinder.php @@ -242,70 +242,54 @@ class ExpertFinder extends Base // ==================== Cron / Auto Fetch ==================== /** - * Daily cron: auto-fetch experts for every journal's fields via queue + * Daily cron: auto-fetch experts for all active fields in t_expert_fetch via queue. + * No longer tied to journals; t_expert_fetch is the sole source of crawl targets. */ public function dailyFetchAll() { - $journalId = intval($this->request->param('journal_id', 0)); - $perPage = max(10, intval($this->request->param('per_page', 200))); - $source = $this->request->param('source', 'pubmed'); - $minYear = intval($this->request->param('min_year', date('Y') - 3)); + $perPage = max(10, intval($this->request->param('per_page', 200))); + $source = $this->request->param('source', 'pubmed'); + $minYear = intval($this->request->param('min_year', date('Y') - 3)); - if ($journalId) { - $journals = Db::name('journal')->field("journal_id,issn,title")->where('journal_id', $journalId)->select(); - } else { - $journals = Db::name('journal')->field("journal_id,issn,title")->where('state', 0)->select(); - } + $fetchList = Db::name('expert_fetch') + ->where('state', 0) + ->select(); - if (empty($journals)) { - return jsonSuccess(['msg' => 'No active journals found', 'queued' => 0]); + if (empty($fetchList)) { + return jsonSuccess(['msg' => 'No active fetch fields found', 'queued' => 0]); } $queued = 0; $skipped = 0; $details = []; $todayStart = strtotime(date('Y-m-d')); - foreach ($journals as $journal) { - $issn = trim($journal['issn'] ?? ''); - if (empty($issn)) continue; - $majors = Db::name('major_to_journal') - ->alias('mtj') - ->join('t_major m', 'm.major_id = mtj.major_id', 'left') - ->where('mtj.journal_issn', $issn) - ->where('mtj.mtj_state', 0) - ->where("m.pid", "<>", 0) - ->where('m.major_state', 0) - ->column('m.major_title'); + foreach ($fetchList as $item) { + $keyword = trim($item['field']); + $itemSource = trim($item['source'] ?: $source); + if ($keyword === '') continue; - $majors = array_unique(array_filter($majors)); - if (empty($majors)) continue; - foreach ($majors as $keyword) { - $keyword = trim($keyword); - if (empty($keyword)) continue; - - $fetchLog = $this->service->getFetchLog($keyword, $source); - if ($fetchLog['last_time'] >= $todayStart) { - $skipped++; - continue; - } - - $delay = $queued * 10; - \think\Queue::later($delay, 'app\api\job\FetchExperts@fire', [ - 'field' => $keyword, - 'source' => $source, - 'per_page' => $perPage, - 'min_year' => $minYear, - 'journal_id' => $journal['journal_id'], - ], 'FetchExperts'); - - $queued++; - $details[] = [ - 'journal' => $journal['title'] ?? $journal['journal_id'], - 'keyword' => $keyword, - 'delay_s' => $delay, - ]; + $fetchLog = $this->service->getFetchLog($keyword, $itemSource); + if ($fetchLog['last_time'] >= $todayStart) { + $skipped++; + continue; } + + $delay = $queued * 10; + \think\Queue::later($delay, 'app\api\job\FetchExperts@fire', [ + 'field' => $keyword, + 'source' => $itemSource, + 'per_page' => $perPage, + 'min_year' => $minYear, + ], 'FetchExperts'); + + $queued++; + $details[] = [ + 'expert_fetch_id' => $item['expert_fetch_id'], + 'field' => $keyword, + 'source' => $itemSource, + 'delay_s' => $delay, + ]; } return jsonSuccess([ @@ -371,7 +355,7 @@ class ExpertFinder extends Base ], 'FetchExperts'); } - public function mytest() + public function fetchOneField() { $data = $this->request->post(); $rule = new Validate([ diff --git a/application/api/controller/ExpertManage.php b/application/api/controller/ExpertManage.php index 8c9983f..a92423c 100644 --- a/application/api/controller/ExpertManage.php +++ b/application/api/controller/ExpertManage.php @@ -37,23 +37,19 @@ class ExpertManage extends Base $data = $this->request->param(); $keyword = trim(isset($data['keyword']) ? $data['keyword'] : ''); $field = trim(isset($data['field']) ? $data['field'] : ''); - $majorId = intval(isset($data['major_id']) ? $data['major_id'] : 0); $state = isset($data['state']) ? $data['state'] : '-1'; $source = trim(isset($data['source']) ? $data['source'] : ''); $page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1)); $pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20)); $query = Db::name('expert')->alias('e'); - $needJoin = ($field !== '' || $majorId > 0); + $needJoin = ($field !== ''); if ($needJoin) { $query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner'); if ($field !== '') { $query->where('ef.field', 'like', '%' . $field . '%'); } - if ($majorId > 0) { - $query->where('ef.major_id', $majorId); - } $query->group('e.expert_id'); } @@ -338,21 +334,17 @@ class ExpertManage extends Base { $data = $this->request->param(); $field = trim(isset($data['field']) ? $data['field'] : ''); - $majorId = intval(isset($data['major_id']) ? $data['major_id'] : 0); $state = isset($data['state']) ? $data['state'] : '-1'; $keyword = trim(isset($data['keyword']) ? $data['keyword'] : ''); $source = trim(isset($data['source']) ? $data['source'] : ''); $query = Db::name('expert')->alias('e'); - if ($field !== '' || $majorId > 0) { + if ($field !== '') { $query->join('t_expert_field ef', 'ef.expert_id = e.expert_id AND ef.state = 0', 'inner'); if ($field !== '') { $query->where('ef.field', 'like', '%' . $field . '%'); } - if ($majorId > 0) { - $query->where('ef.major_id', $majorId); - } $query->group('e.expert_id'); } @@ -490,4 +482,162 @@ class ExpertManage extends Base ]); } } + + // ==================== Expert Fetch Field Management ==================== + + /** + * 获取抓取领域列表 + * 参数: state(-1不过滤), keyword(搜索field), pageIndex, pageSize + */ + public function getFetchList() + { + $data = $this->request->param(); + $state = isset($data['state']) ? $data['state'] : '-1'; + $keyword = trim(isset($data['keyword']) ? $data['keyword'] : ''); + $page = max(1, intval(isset($data['pageIndex']) ? $data['pageIndex'] : 1)); + $pageSize = max(1, intval(isset($data['pageSize']) ? $data['pageSize'] : 20)); + + $query = Db::name('expert_fetch'); + + if ($state !== '-1' && $state !== '') { + $query->where('state', intval($state)); + } + if ($keyword !== '') { + $query->where('field', 'like', '%' . $keyword . '%'); + } + + $countQuery = Db::name('expert_fetch'); + if ($state !== '-1' && $state !== '') { + $countQuery->where('state', intval($state)); + } + if ($keyword !== '') { + $countQuery->where('field', 'like', '%' . $keyword . '%'); + } + + $total = $countQuery->count(); + $list = $query->order('expert_fetch_id desc')->page($page, $pageSize)->select(); + + foreach ($list as &$item) { + $item['last_time_text'] = $item['last_time'] ? date('Y-m-d H:i:s', $item['last_time']) : ''; + $item['ctime_text'] = $item['ctime'] ? date('Y-m-d H:i:s', $item['ctime']) : ''; + $fieldName = trim($item['field']); + + $item['journal_count'] = Db::name('journal_promotion_field') + ->where('expert_fetch_id', $item['expert_fetch_id']) + ->where('state', 0) + ->count(); + + // 与推广选人一致:按字段关键词匹配,统计可用专家(去重) + $item['expert_count'] = Db::name('expert_field')->alias('ef') + ->join('t_expert e', 'e.expert_id = ef.expert_id', 'inner') + ->where('ef.state', 0) + ->where('e.state', 0) + ->where('ef.field', 'like', '%' . $fieldName . '%') + ->count('distinct ef.expert_id'); + + + } + + return jsonSuccess([ + 'list' => $list, + 'total' => $total, + 'pageIndex' => $page, + 'pageSize' => $pageSize, + 'totalPages' => $total > 0 ? ceil($total / $pageSize) : 0, + ]); + } + + /** + * 新增抓取领域 + * 参数: field(必填), source(选填,默认pubmed) + */ + public function addFetchField() + { + $data = $this->request->post(); + $field = trim(isset($data['field']) ? $data['field'] : ''); + $source = trim(isset($data['source']) ? $data['source'] : 'pubmed'); + + if ($field === '') { + return jsonError('field不能为空'); + } + + $exists = Db::name('expert_fetch') + ->where('field', $field) + ->where('source', $source) + ->find(); + if ($exists) { + if ($exists['state'] == 1) { + Db::name('expert_fetch') + ->where('expert_fetch_id', $exists['expert_fetch_id']) + ->update(['state' => 0]); + return jsonSuccess(['expert_fetch_id' => $exists['expert_fetch_id'], 'msg' => 'reactivated']); + } + return jsonError('该领域已存在 (expert_fetch_id=' . $exists['expert_fetch_id'] . ')'); + } + + $id = Db::name('expert_fetch')->insertGetId([ + 'field' => mb_substr($field, 0, 128), + 'source' => mb_substr($source, 0, 128), + 'last_page' => 0, + 'total_pages' => 0, + 'last_time' => 0, + 'state' => 0, + 'ctime' => time(), + ]); + + return jsonSuccess(['expert_fetch_id' => $id]); + } + + /** + * 编辑抓取领域 + * 参数: expert_fetch_id(必填), field, source, state + */ + public function editFetchField() + { + $data = $this->request->post(); + $id = intval(isset($data['expert_fetch_id']) ? $data['expert_fetch_id'] : 0); + if (!$id) { + return jsonError('expert_fetch_id is required'); + } + + $record = Db::name('expert_fetch')->where('expert_fetch_id', $id)->find(); + if (!$record) { + return jsonError('记录不存在'); + } + + $update = []; + if (isset($data['field'])) $update['field'] = mb_substr(trim($data['field']), 0, 128); + if (isset($data['source'])) $update['source'] = mb_substr(trim($data['source']), 0, 128); + if (isset($data['state'])) $update['state'] = intval($data['state']); + + if (!empty($update)) { + Db::name('expert_fetch')->where('expert_fetch_id', $id)->update($update); + } + + return jsonSuccess(['expert_fetch_id' => $id]); + } + + /** + * 删除/停用抓取领域(软删除 state=1) + * 参数: expert_fetch_id(必填), hard(传1物理删除) + */ + public function deleteFetchField() + { + $data = $this->request->post(); + $id = intval(isset($data['expert_fetch_id']) ? $data['expert_fetch_id'] : 0); + $hard = intval(isset($data['hard']) ? $data['hard'] : 0); + + if (!$id) { + return jsonError('expert_fetch_id is required'); + } + + if ($hard) { + Db::name('journal_promotion_field')->where('expert_fetch_id', $id)->delete(); + Db::name('expert_fetch')->where('expert_fetch_id', $id)->delete(); + } else { + Db::name('expert_fetch')->where('expert_fetch_id', $id)->update(['state' => 1]); + } + + return jsonSuccess([]); + } } diff --git a/application/api/controller/Preaccept.php b/application/api/controller/Preaccept.php index 50deb2f..4cabda8 100644 --- a/application/api/controller/Preaccept.php +++ b/application/api/controller/Preaccept.php @@ -843,14 +843,14 @@ class Preaccept extends Base } $am_info = $this->article_main_obj->where("am_id",$data['am_id'])->find(); //上一行,空行 - $p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select(); - if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){ - $this->addBRow($am_info['article_id'],$p_list[0]['am_id']); - } - $n_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort",">",$am_info['sort'])->whereIn("state",[0,2])->order("sort asc")->limit(1)->select(); - if($n_list[0]['type']>0||$n_list[0]['content']!=""){ - $this->addBRow($am_info['article_id'],$data['am_id']); - } +// $p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select(); +// if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){ +// $this->addBRow($am_info['article_id'],$p_list[0]['am_id']); +// } +// $n_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort",">",$am_info['sort'])->whereIn("state",[0,2])->order("sort asc")->limit(1)->select(); +// if($n_list[0]['type']>0||$n_list[0]['content']!=""){ +// $this->addBRow($am_info['article_id'],$data['am_id']); +// } $this->article_main_obj->where("am_id",$data['am_id'])->update(["is_h1"=>1,"is_h2"=>0,"is_h3"=>0]); // return jsonSuccess([]); //返回数据 20260119 start @@ -872,10 +872,10 @@ class Preaccept extends Base } $am_info = $this->article_main_obj->where("am_id",$data['am_id'])->find(); //上一行,空行 - $p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select(); - if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){ - $this->addBRow($am_info['article_id'],$p_list[0]['am_id']); - } +// $p_list = $this->article_main_obj->where("article_id",$am_info['article_id'])->where("sort","<",$am_info['sort'])->whereIn("state",[0,2])->order("sort desc")->limit(1)->select(); +// if($p_list&&($p_list[0]['type']>0||$p_list[0]['content']!="")){ +// $this->addBRow($am_info['article_id'],$p_list[0]['am_id']); +// } $this->article_main_obj->where("am_id",$data['am_id'])->update(["is_h1"=>0,"is_h2"=>1,"is_h3"=>0]); // return jsonSuccess([]); //返回数据 20260119 start diff --git a/application/common/ExpertFinderService.php b/application/common/ExpertFinderService.php index f743db6..b11e975 100644 --- a/application/common/ExpertFinderService.php +++ b/application/common/ExpertFinderService.php @@ -36,10 +36,15 @@ class ExpertFinderService $result = $this->searchViaPubMed($field, $perPage, $minYear, $page); } + if(!isset($result['total'])){ + return [ + "has_more"=>"no" + ]; + } $saveResult = $this->saveExperts($result['experts'], $field, $source); - $nextPage = $result['has_more'] ? $page : 0; - $totalPages = isset($result['total_pages']) ? $result['total_pages'] : 0; + $nextPage = $result['has_more'] ? $page : $fetchLog['last_page']; + $totalPages = $result['total_pages'] ?? $fetchLog['total_pages']; $this->updateFetchLog($field, $source, $nextPage, $totalPages); return [ @@ -48,6 +53,7 @@ class ExpertFinderService 'experts_found' => $result['total'], 'saved_new' => $saveResult['inserted'], 'saved_exist' => $saveResult['existing'], + 'list' => $result['experts'], 'field_enriched' => $saveResult['field_enriched'], 'has_more' => $result['has_more'], ]; @@ -68,6 +74,8 @@ class ExpertFinderService $fieldEnrich = 0; foreach ($experts as $expert) { + + $email = strtolower(trim($expert['email'])); if (empty($email)) { continue; @@ -94,6 +102,9 @@ class ExpertFinderService try { $expertId = Db::name('expert')->insertGetId($insert); $this->enrichExpertField($expertId, $field); + if(isset($expert['papers'])&&is_array($expert['papers'])){ + $this->savePaper($expertId, $expert['papers']); + } $inserted++; } catch (\Exception $e) { $existing++; @@ -103,6 +114,25 @@ class ExpertFinderService return ['inserted' => $inserted, 'existing' => $existing, 'field_enriched' => $fieldEnrich]; } + private function savePaper($expertId, $papers) + { + foreach ($papers as $paper){ + $check = Db::name('expert_paper')->where("expert_id",$expertId)->where('paper_article_id',$paper['article_id'])->find(); + if($check){ + continue; + } + $insert = [ + 'expert_id' => $expertId, + 'paper_title' => isset($paper['title'])?mb_substr($paper['title'], 0, 255):"", + 'paper_article_id' => $paper['article_id'] ?? 0, + 'paper_journal' => isset($paper['journal'])?mb_substr($paper['journal'], 0, 128):"", + 'ctime' => time(), + ]; + Db::name('expert_paper')->insert($insert); + } + } + + public function getFetchLog($field, $source) { $log = Db::name('expert_fetch') @@ -519,11 +549,8 @@ class ExpertFinderService ->where('state', 0) ->find(); if ($exists) return 0; - $major = Db::name("major")->where("major_title",$field)->where("state",0)->find(); - $major_id = $major ? $major['major_id'] : 0; Db::name('expert_field')->insert([ 'expert_id' => $expertId, - 'major_id' => $major_id, 'field' => mb_substr($field, 0, 128), 'state' => 0, ]);