service = new ExpertFinderService(); } /** * Main search endpoint */ public function search() { $keyword = trim($this->request->param('keyword', '')); $page = max(1, intval($this->request->param('page', 1))); $perPage = max(10, min(intval($this->request->param('per_page', 100)), 100)); $minYear = intval($this->request->param('min_year', date('Y') - 3)); $source = $this->request->param('source', 'pubmed'); if (empty($keyword)) { return jsonError('keyword is required'); } $cacheKey = 'expert_finder_' . md5($keyword . $page . $perPage . $minYear . $source); $cached = Cache::get($cacheKey); if ($cached) { return jsonSuccess($cached); } try { $result = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source); } catch (\Exception $e) { return jsonError('Search failed: ' . $e->getMessage()); } $saveResult = $this->service->saveExperts($result['experts'], $keyword, $source); $result['saved_new'] = $saveResult['inserted']; $result['saved_exist'] = $saveResult['existing']; Cache::set($cacheKey, $result, 3600); return jsonSuccess($result); } /** * Get experts from local database */ public function getList() { $field = trim($this->request->param('field', '')); $majorId = intval($this->request->param('major_id', 0)); $state = $this->request->param('state', '-1'); $keyword = trim($this->request->param('keyword', '')); $noRecent = intval($this->request->param('no_recent', 0)); $recentDays = max(1, intval($this->request->param('recent_days', 30))); $page = max(1, intval($this->request->param('page', 1))); $perPage = max(1, min(intval($this->request->param('per_page', 20)), 100)); $minExperts = max(0, intval($this->request->param('min_experts', 50))); $query = Db::name('expert')->alias('e'); $needJoin = ($field !== '' || $majorId > 0); 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'); } if ($state !== '-1' && $state !== '') { $query->where('e.state', intval($state)); } if ($keyword !== '') { $query->where('e.name|e.email|e.affiliation', 'like', '%' . $keyword . '%'); } if ($noRecent) { $cutoff = time() - ($recentDays * 86400); $query->where(function ($q) use ($cutoff) { $q->where('e.ltime', 0)->whereOr('e.ltime', '<', $cutoff); }); } $countQuery = clone $query; $total = $countQuery->count('distinct e.expert_id'); $list = $query ->field('e.*') ->order('e.ctime desc') ->page($page, $perPage) ->select(); foreach ($list as &$item) { $item['fields'] = Db::name('expert_field') ->where('expert_id', $item['expert_id']) ->where('state', 0) ->column('field'); } $fetching = false; if ($field !== '' && $total < $minExperts && $minExperts > 0) { $this->triggerBackgroundFetch($field); $fetching = true; } return jsonSuccess([ 'list' => $list, 'total' => $total, 'page' => $page, 'per_page' => $perPage, 'total_pages' => $total > 0 ? ceil($total / $perPage) : 0, 'fetching' => $fetching, ]); } /** * Get all fields associated with an expert */ public function getExpertFields() { $expertId = intval($this->request->param('expert_id', 0)); if (!$expertId) { return jsonError('expert_id is required'); } $fields = Db::name('expert_field') ->where('expert_id', $expertId) ->where('state', 0) ->select(); return jsonSuccess($fields); } /** * Update expert state */ public function updateState() { $expertId = $this->request->param('expert_id', ''); $state = intval($this->request->param('state', 0)); if (empty($expertId)) { return jsonError('expert_id is required'); } if ($state < 0 || $state > 5) { return jsonError('state must be 0-5'); } $ids = array_map('intval', explode(',', $expertId)); $count = Db::name('expert')->where('expert_id', 'in', $ids)->update(['state' => $state]); return jsonSuccess(['updated' => $count]); } /** * Delete expert (soft: set state=5 blacklist, or hard delete) */ public function deleteExpert() { $expertId = $this->request->param('expert_id', ''); $hard = intval($this->request->param('hard', 0)); if (empty($expertId)) { return jsonError('expert_id is required'); } $ids = array_map('intval', explode(',', $expertId)); if ($hard) { $count = Db::name('expert')->where('expert_id', 'in', $ids)->delete(); } else { $count = Db::name('expert')->where('expert_id', 'in', $ids)->update(['state' => 5]); } return jsonSuccess(['affected' => $count]); } /** * Export search results to Excel */ public function export() { $keyword = trim($this->request->param('keyword', '')); $page = max(1, intval($this->request->param('page', 1))); $perPage = max(10, min(intval($this->request->param('per_page', 100)), 100)); $minYear = intval($this->request->param('min_year', date('Y') - 3)); $source = $this->request->param('source', 'pubmed'); if (empty($keyword)) { return jsonError('keyword is required'); } $cacheKey = 'expert_finder_' . md5($keyword . $page . $perPage . $minYear . $source); $cached = Cache::get($cacheKey); if (!$cached) { try { $cached = $this->service->searchExperts($keyword, $perPage, $minYear, $page, $source); Cache::set($cacheKey, $cached, 3600); } catch (\Exception $e) { return jsonError('Search failed: ' . $e->getMessage()); } } if (empty($cached['experts'])) { return jsonError('No experts found to export'); } return $this->generateExcel($cached['experts'], $keyword, $page); } /** * Clear search cache */ public function clearCache() { $keyword = trim($this->request->param('keyword', '')); $maxResults = intval($this->request->param('max_results', 200)); $minYear = intval($this->request->param('min_year', date('Y') - 3)); $source = $this->request->param('source', 'pubmed'); $cacheKey = 'expert_finder_' . md5($keyword . $maxResults . $minYear . $source); Cache::rm($cacheKey); return jsonSuccess(['msg' => 'Cache cleared']); } // ==================== Cron / Auto Fetch ==================== /** * Daily cron: auto-fetch experts for every journal's fields via queue */ 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)); 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(); } if (empty($journals)) { return jsonSuccess(['msg' => 'No active journals 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'); $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, ]; } } return jsonSuccess([ 'queued' => $queued, 'skipped' => $skipped, 'details' => $details, ]); } /** * Cron job: daily fetch experts for given keywords */ public function cronFetch() { $keywordsStr = trim($this->request->param('keywords', '')); $source = $this->request->param('source', 'pubmed'); $perPage = max(10, min(intval($this->request->param('per_page', 100)), 100)); $minYear = intval($this->request->param('min_year', date('Y') - 3)); if (empty($keywordsStr)) { return jsonError('keywords is required'); } set_time_limit(0); $keywords = array_map('trim', explode(',', $keywordsStr)); $report = []; foreach ($keywords as $kw) { if (empty($kw)) continue; try { $result = $this->service->doFetchForField($kw, $source, $perPage, $minYear); $report[] = $result; } catch (\Exception $e) { $report[] = [ 'keyword' => $kw, 'error' => $e->getMessage(), ]; } sleep(2); } return jsonSuccess(['report' => $report]); } /** * Trigger a background fetch for a specific field via queue */ private function triggerBackgroundFetch($field) { $lockKey = 'fetch_lock_' . md5($field); if (Cache::get($lockKey)) { return; } Cache::set($lockKey, 1, 300); \think\Queue::push('app\api\job\FetchExperts@fire', [ 'field' => $field, 'source' => 'pubmed', 'per_page' => 100, 'min_year' => date('Y') - 3, ], 'FetchExperts'); } public function mytest() { $data = $this->request->post(); $rule = new Validate([ "field" => "require" ]); if (!$rule->check($data)) { return jsonError($rule->getError()); } $res = $this->service->doFetchForField($data['field'], "pubmed", 100, date('Y') - 3); return jsonSuccess($res); } // ==================== Excel Export ==================== private function generateExcel($experts, $keyword, $page = 1) { vendor("PHPExcel.PHPExcel"); $objPHPExcel = new \PHPExcel(); $sheet = $objPHPExcel->getActiveSheet(); $sheet->setTitle('Experts'); $headers = ['A' => '#', 'B' => 'Name', 'C' => 'Email', 'D' => 'Affiliation', 'E' => 'Paper Count', 'F' => 'Representative Papers']; foreach ($headers as $col => $header) { $sheet->setCellValue($col . '1', $header); } $headerStyle = [ 'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']], 'fill' => ['type' => \PHPExcel_Style_Fill::FILL_SOLID, 'startcolor' => ['rgb' => '4472C4']], 'alignment' => ['horizontal' => \PHPExcel_Style_Alignment::HORIZONTAL_CENTER], ]; $sheet->getStyle('A1:F1')->applyFromArray($headerStyle); foreach ($experts as $i => $expert) { $row = $i + 2; $paperTitles = array_map(function ($p) { return $p['title']; }, $expert['papers']); $sheet->setCellValue('A' . $row, $i + 1); $sheet->setCellValue('B' . $row, $expert['name']); $sheet->setCellValue('C' . $row, $expert['email']); $sheet->setCellValue('D' . $row, $expert['affiliation']); $sheet->setCellValue('E' . $row, $expert['paper_count']); $sheet->setCellValue('F' . $row, implode("\n", $paperTitles)); } $sheet->getColumnDimension('A')->setWidth(6); $sheet->getColumnDimension('B')->setWidth(25); $sheet->getColumnDimension('C')->setWidth(35); $sheet->getColumnDimension('D')->setWidth(50); $sheet->getColumnDimension('E')->setWidth(12); $sheet->getColumnDimension('F')->setWidth(60); $filename = 'experts_' . preg_replace('/[^a-zA-Z0-9]/', '_', $keyword) . '_p' . $page . '_' . date('Ymd_His') . '.xlsx'; $filepath = ROOT_PATH . 'public' . DS . 'exports' . DS . $filename; $dir = ROOT_PATH . 'public' . DS . 'exports'; if (!is_dir($dir)) { mkdir($dir, 0777, true); } $writer = \PHPExcel_IOFactory::createWriter($objPHPExcel, 'Excel2007'); $writer->save($filepath); return jsonSuccess([ 'file_url' => '/exports/' . $filename, 'file_name' => $filename, 'count' => count($experts), ]); } }