This commit is contained in:
wangjinlei
2026-04-17 09:51:09 +08:00
parent e9a354c663
commit 4417a7ea28
5 changed files with 314 additions and 41 deletions

View File

@@ -0,0 +1,243 @@
<?php
namespace app\api\controller;
use think\Db;
class Country extends Base
{
public function __construct(\think\Request $request = null)
{
parent::__construct($request);
}
/**
* 国家列表(支持筛选 + 分页)
*
* 参数:
* keyword - 搜索中文名/英文名/code
* is_hot - 0/1 筛选热门
* partition - 分区 1/2/3
* page - 页码默认1
* per_page - 每页条数默认20最大100
*/
public function getList()
{
$keyword = trim($this->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]);
}
}

View File

@@ -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,
]);
}

View File

@@ -7,33 +7,31 @@ use app\common\ExpertFinderService;
/**
* 队列任务:用本地大模型从 affiliation 推断国家,写入 expert.country_id / country。
* 处理完当前专家后,自动找下一个推入队列(链式执行),直到全部处理完。
* 处理完当前专家后,自动找下一个推入同一队列(链式执行),直到全部处理完。
*
* 队列名FetchExperts
* 启动 workerphp 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);
}
}

View File

@@ -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))),

View File

@@ -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'] ?? '',
];
}