Files
tougao/application/api/controller/ExpertFinder.php
2026-03-13 17:19:34 +08:00

448 lines
15 KiB
PHP

<?php
namespace app\api\controller;
use think\Cache;
use think\Db;
use think\Validate;
use app\common\ExpertFinderService;
class ExpertFinder extends Base
{
private $service;
public function __construct(\think\Request $request = null)
{
parent::__construct($request);
$this->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),
]);
}
}