diff --git a/application/api/controller/Country.php b/application/api/controller/Country.php new file mode 100644 index 0000000..39804e0 --- /dev/null +++ b/application/api/controller/Country.php @@ -0,0 +1,243 @@ +request->param('keyword', '')); + $isHot = $this->request->param('is_hot', ''); + $partition = $this->request->param('partition', ''); + $page = max(1, intval($this->request->param('page', 1))); + $perPage = max(1, min(intval($this->request->param('per_page', 20)), 100)); + + $query = Db::name('country'); + + if ($keyword !== '') { + $query->where('zh_name|en_name|code', 'like', '%' . $keyword . '%'); + } + if ($isHot !== '' && $isHot !== '-1') { + $query->where('is_hot', intval($isHot)); + } + if ($partition !== '' && $partition !== '-1') { + $query->where('partition', intval($partition)); + } + + $total = (clone $query)->count(); + + $list = $query + ->order('is_hot desc, zh_name asc') + ->page($page, $perPage) + ->select(); + + return jsonSuccess([ + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'total_pages' => $total > 0 ? ceil($total / $perPage) : 0, + ]); + } + + /** + * 国家下拉选项(不分页,用于筛选器) + * + * 参数: + * hot_only - 1 则只返回热门国家 + * partition - 按分区筛选 + */ + public function getOptions() + { + $hotOnly = intval($this->request->param('hot_only', 0)); + $partition = $this->request->param('partition', ''); + + $query = Db::name('country'); + + if ($hotOnly) { + $query->where('is_hot', 1); + } + if ($partition !== '' && $partition !== '-1') { + $query->where('partition', intval($partition)); + } + + $list = $query + ->field('country_id, zh_name, en_name, code, is_hot, partition') + ->order('is_hot desc, zh_name asc') + ->select(); + + return jsonSuccess($list); + } + + /** + * 国家详情 + */ + public function getDetail() + { + $id = intval($this->request->param('country_id', 0)); + if (!$id) { + return jsonError('country_id is required'); + } + + $row = Db::name('country')->where('country_id', $id)->find(); + if (!$row) { + return jsonError('Country not found'); + } + + return jsonSuccess($row); + } + + /** + * 新增国家 + */ + public function add() + { + $data = $this->request->post(); + + $zhName = trim(isset($data['zh_name']) ? $data['zh_name'] : ''); + $enName = trim(isset($data['en_name']) ? $data['en_name'] : ''); + $code = strtoupper(trim(isset($data['code']) ? $data['code'] : '')); + + if ($zhName === '' && $enName === '') { + return jsonError('zh_name 或 en_name 至少填一个'); + } + + if ($code !== '') { + $exists = Db::name('country')->where('code', $code)->find(); + if ($exists) { + return jsonError('code 已存在: ' . $code); + } + } + + $id = Db::name('country')->insertGetId([ + 'zh_name' => mb_substr($zhName, 0, 32), + 'en_name' => mb_substr($enName, 0, 256), + 'code' => mb_substr($code, 0, 32), + 'is_hot' => intval(isset($data['is_hot']) ? $data['is_hot'] : 0), + 'partition' => intval(isset($data['partition']) ? $data['partition'] : 2), + ]); + + return jsonSuccess(['country_id' => $id]); + } + + /** + * 编辑国家 + */ + public function edit() + { + $data = $this->request->post(); + $id = intval(isset($data['country_id']) ? $data['country_id'] : 0); + if (!$id) { + return jsonError('country_id is required'); + } + + $row = Db::name('country')->where('country_id', $id)->find(); + if (!$row) { + return jsonError('Country not found'); + } + + $update = []; + + if (isset($data['zh_name'])) { + $update['zh_name'] = mb_substr(trim($data['zh_name']), 0, 32); + } + if (isset($data['en_name'])) { + $update['en_name'] = mb_substr(trim($data['en_name']), 0, 256); + } + if (isset($data['code'])) { + $code = strtoupper(trim($data['code'])); + $dup = Db::name('country')->where('code', $code)->where('country_id', '<>', $id)->find(); + if ($dup) { + return jsonError('code 已被其他国家使用: ' . $code); + } + $update['code'] = mb_substr($code, 0, 32); + } + if (isset($data['is_hot'])) { + $update['is_hot'] = intval($data['is_hot']); + } + if (isset($data['partition'])) { + $update['partition'] = intval($data['partition']); + } + + if (empty($update)) { + return jsonError('没有可更新的字段'); + } + + Db::name('country')->where('country_id', $id)->update($update); + + return jsonSuccess([]); + } + + /** + * 删除国家 + */ + public function delete() + { + $id = intval($this->request->param('country_id', 0)); + if (!$id) { + return jsonError('country_id is required'); + } + + $used = Db::name('expert')->where('country_id', $id)->count(); + if ($used > 0) { + return jsonError('该国家下有 ' . $used . ' 位专家关联,无法删除'); + } + + Db::name('country')->where('country_id', $id)->delete(); + + return jsonSuccess([]); + } + + /** + * 批量设置热门 + */ + public function setHot() + { + $ids = $this->request->param('country_ids', ''); + $isHot = intval($this->request->param('is_hot', 1)); + + if (empty($ids)) { + return jsonError('country_ids is required'); + } + + $idArr = array_map('intval', explode(',', $ids)); + $count = Db::name('country')->where('country_id', 'in', $idArr)->update(['is_hot' => $isHot]); + + return jsonSuccess(['affected' => $count]); + } + + /** + * 批量设置分区 + */ + public function setPartition() + { + $ids = $this->request->param('country_ids', ''); + $partition = intval($this->request->param('partition', 2)); + + if (empty($ids)) { + return jsonError('country_ids is required'); + } + + $idArr = array_map('intval', explode(',', $ids)); + $count = Db::name('country')->where('country_id', 'in', $idArr)->update(['partition' => $partition]); + + return jsonSuccess(['affected' => $count]); + } +} diff --git a/application/api/controller/ExpertFinder.php b/application/api/controller/ExpertFinder.php index 2f1ff8e..65f1c08 100644 --- a/application/api/controller/ExpertFinder.php +++ b/application/api/controller/ExpertFinder.php @@ -85,13 +85,14 @@ class ExpertFinder extends Base } /** - * 启动国家解析:找到第一个缺 country 的专家推入队列, - * 队列处理完后会自动链式找下一个,直到全部处理完。 - * 只需调一次即可。 + * 启动国家解析:同时启动两条链,分别用不同模型并行处理。 + * 只需调一次,两条链各自链式执行直到全部处理完。 */ public function batchFillCountry(){ $service = new ExpertFinderService(); - $started = $service->enqueueNextCountryFill(0); + + $chain1 = $service->enqueueNextCountryFill(0, 'FetchExpertCity', ''); + $chain2 = $service->enqueueNextCountryFill(0, 'FetchExpertCity1', 'http://125.39.141.154:10002/v1/chat/completions'); $pending = Db::name('expert') ->where('affiliation', '<>', '') @@ -100,7 +101,8 @@ class ExpertFinder extends Base ->count(); return jsonSuccess([ - 'started' => $started, + 'chain1_started' => $chain1, + 'chain2_started' => $chain2, 'pending' => $pending, ]); } diff --git a/application/api/job/FillExpertCountry.php b/application/api/job/FillExpertCountry.php index f6fd8a5..59fd058 100644 --- a/application/api/job/FillExpertCountry.php +++ b/application/api/job/FillExpertCountry.php @@ -7,33 +7,31 @@ use app\common\ExpertFinderService; /** * 队列任务:用本地大模型从 affiliation 推断国家,写入 expert.country_id / country。 - * 处理完当前专家后,自动找下一个推入队列(链式执行),直到全部处理完。 + * 处理完当前专家后,自动找下一个推入同一队列(链式执行),直到全部处理完。 * - * 队列名:FetchExperts - * 启动 worker:php think queue:listen --queue FetchExperts + * 支持多队列并行:通过 $data['queue'] 和 $data['chat_url'] 区分不同的链/模型。 */ class FillExpertCountry { public function fire(Job $job, $data) { - - - $expertId = intval(isset($data['expert_id']) ? $data['expert_id'] : 0); $affiliation = isset($data['affiliation']) ? trim((string)$data['affiliation']) : ''; + $queue = isset($data['queue']) ? (string)$data['queue'] : 'FetchExperts'; + $chatUrl = isset($data['chat_url']) ? (string)$data['chat_url'] : ''; $service = new ExpertFinderService(); if ($expertId && $affiliation !== '') { try { - $service->fillExpertCountry($expertId, $affiliation); + $service->fillExpertCountry($expertId, $affiliation, $chatUrl); } catch (\Exception $e) { - $service->log('[FillExpertCountry] expert_id=' . $expertId . ' exception=' . $e->getMessage()); + $service->log('[FillExpertCountry] expert_id=' . $expertId . ' queue=' . $queue . ' exception=' . $e->getMessage()); } } $job->delete(); - $service->enqueueNextCountryFill(1); + $service->enqueueNextCountryFill(1, $queue, $chatUrl); } } diff --git a/application/common/ExpertFinderService.php b/application/common/ExpertFinderService.php index acb6815..bd5a67e 100644 --- a/application/common/ExpertFinderService.php +++ b/application/common/ExpertFinderService.php @@ -589,7 +589,15 @@ class ExpertFinderService * @param int $delay 延迟秒数(防止打满模型,默认1秒) * @return bool 是否成功推入了一条 */ - public function enqueueNextCountryFill($delay = 1) + /** + * 启动国家解析链:找到下一个缺国家的专家推入指定队列。 + * + * @param int $delay 延迟秒数 + * @param string $queue 队列名(不同队列跑不同 worker,互不阻塞) + * @param string $chatUrl 该链使用的模型地址(为空则用默认) + * @return bool + */ + public function enqueueNextCountryFill($delay = 1, $queue = 'FetchExperts', $chatUrl = '') { $row = Db::name('expert') ->where('affiliation', '<>', '') @@ -600,19 +608,22 @@ class ExpertFinderService ->find(); if (!$row) { - $this->log('[CountryFill] no more pending experts'); + $this->log('[CountryFill] no more pending experts, queue=' . $queue); return false; } $data = [ 'expert_id' => intval($row['expert_id']), 'affiliation' => trim((string)$row['affiliation']), + 'queue' => $queue, + 'chat_url' => $chatUrl, ]; + $jobClass = 'app\api\job\FillExpertCountry@fire'; if ($delay > 0) { - Queue::later($delay, 'app\api\job\FillExpertCountry@fire', $data, 'FetchExpertCity'); + Queue::later($delay, $jobClass, $data, $queue); } else { - Queue::push('app\api\job\FillExpertCountry@fire', $data, 'FetchExpertCity'); + Queue::push($jobClass, $data, $queue); } return true; @@ -621,7 +632,7 @@ class ExpertFinderService /** * 对单个专家执行国家解析(同步),由队列 Job FillExpertCountry 调用,也可直接调用测试。 */ - public function fillExpertCountry($expertId, $affiliation) + public function fillExpertCountry($expertId, $affiliation, $chatUrl = '') { $affiliation = trim((string)$affiliation); if ($affiliation === '') { @@ -629,8 +640,11 @@ class ExpertFinderService return; } + $defaultUrl = trim((string)Env::get('expert_country_chat_url', Env::get('citation_chat_url', 'http://chat.taimed.cn/v1/chat/completions'))); + $url = ($chatUrl !== '') ? $chatUrl : $defaultUrl; + $resolver = new CountryResolverService([ - 'chat_url' => trim((string)Env::get('expert_country_chat_url', Env::get('citation_chat_url', 'http://chat.taimed.cn/v1/chat/completions'))), + 'chat_url' => $url, 'chat_model' => trim((string)Env::get('expert_country_chat_model', Env::get('citation_chat_model', 'gpt-4.1'))), 'api_key' => trim((string)Env::get('expert_country_chat_api_key', Env::get('citation_chat_api_key', ''))), 'timeout' => max(20, intval(Env::get('expert_country_chat_timeout', 60))), diff --git a/application/common/PromotionService.php b/application/common/PromotionService.php index e5b8897..c68f3ac 100644 --- a/application/common/PromotionService.php +++ b/application/common/PromotionService.php @@ -90,16 +90,23 @@ class PromotionService $body = $logEntry['body_prepared']; } else { $journal = Db::name('journal')->where('journal_id', $task['journal_id'])->find(); - $expert_fields = Db::name('expert_fields')->where('expert_id', $expert['expert_id'])->select(); - $field_str = ''; - foreach ($expert_fields as $field){ - if($field_str != ''){ - $field_str .= ','.$field['field_name']; - }else{ - $field_str = $field['field_name']; + $expert_fields = Db::name('expert_field') + ->where('expert_id', $expert['expert_id']) + ->where('state', 0) + ->select(); + $fieldSet = []; + $representativeTitle = ''; + foreach ($expert_fields as $ef) { + $fn = trim($ef['field']); + if ($fn !== '' && !in_array($fn, $fieldSet)) { + $fieldSet[] = $fn; + } + if ($representativeTitle === '' && !empty($ef['paper_title'])) { + $representativeTitle = trim($ef['paper_title']); } } - $expert['fields'] = $field_str; + $expert['fields'] = implode(',', $fieldSet); + $expert['representative_work_title'] = $representativeTitle; $expertVars = $this->buildExpertVars($expert); $journalVars = $this->buildJournalVars($journal); $vars = array_merge($journalVars, $expertVars); @@ -204,19 +211,27 @@ class PromotionService ]); $failed++; continue; - }else{ - $expert_fields = Db::name('expert_field')->where('expert_id', $expert['expert_id'])->select(); - $field_str = ''; - foreach ($expert_fields as $field){ - if($field_str != ''){ - $field_str .= ','.$field['field']; - }else{ - $field_str = $field['field']; - } - } - $expert['fields'] = $field_str; } + $expert_fields = Db::name('expert_field') + ->where('expert_id', $expert['expert_id']) + ->where('state', 0) + ->select(); + + $fieldSet = []; + $representativeTitle = ''; + foreach ($expert_fields as $ef) { + $fn = trim($ef['field']); + if ($fn !== '' && !in_array($fn, $fieldSet)) { + $fieldSet[] = $fn; + } + if ($representativeTitle === '' && !empty($ef['paper_title'])) { + $representativeTitle = trim($ef['paper_title']); + } + } + $expert['fields'] = implode(',', $fieldSet); + $expert['representative_work_title'] = $representativeTitle; + $expertVars = $this->buildExpertVars($expert); $vars = array_merge($journalVars, $expertVars); $rendered = $this->renderFromTemplate( @@ -477,11 +492,12 @@ class PromotionService public function buildExpertVars($expert) { return [ - 'expert_title' => "Ph.D", + 'expert_title' => "Ph.D", 'expert_name' => $expert['name'] ?? '', 'expert_email' => $expert['email'] ?? '', 'expert_affiliation' => $expert['affiliation'] ?? '', - 'expert_field' => $expert['field'] ?? '', + 'expert_field' => $expert['fields'] ?? ($expert['field'] ?? ''), + 'representative_work_title' => $expert['representative_work_title'] ?? '', ]; }